From 144047d0e19a2c091f18247880f441875740c9db Mon Sep 17 00:00:00 2001 From: AgentKush Date: Sat, 4 Apr 2026 18:10:23 +0100 Subject: [PATCH 01/25] Fix .ruby-version to match Gemfile ruby requirement (3.4.9) The Gemfile specifies ruby "3.4.9" but .ruby-version was still 3.4.8, causing CI to install the wrong Ruby version and fail with "Your Ruby version is 3.4.8, but your Gemfile specified 3.4.9". Co-Authored-By: Claude Opus 4.6 --- .ruby-version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.ruby-version b/.ruby-version index 7921bd0..7bcbb38 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.4.8 +3.4.9 From 1092b923c05bba40902464ed2a6ae1ec4fbed2db Mon Sep 17 00:00:00 2001 From: AgentKush Date: Sat, 4 Apr 2026 02:07:36 +0100 Subject: [PATCH 02/25] Enable Content-Security-Policy headers in report-only mode The CSP initializer was entirely commented out, leaving the application with no Content-Security-Policy headers. This is a security risk as it allows unrestricted loading of scripts, styles, and other resources. Configure CSP with sensible defaults for this app's needs: - default_src/script_src/connect_src: self + https - img_src/font_src: self + https + data URIs - object_src: none (blocks Flash/Java embeds) - style_src includes unsafe_inline for Tailwind compatibility - Nonce-based script protection via importmap Starts in report-only mode so violations are logged without breaking the site. Once verified in production, report_only can be removed. Fixes the medium-severity CSP bug reported in #76. Co-Authored-By: Claude Opus 4.6 --- .../initializers/content_security_policy.rb | 40 +++++++++---------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb index 53538c1..322c5f4 100644 --- a/config/initializers/content_security_policy.rb +++ b/config/initializers/content_security_policy.rb @@ -1,27 +1,25 @@ # frozen_string_literal: true -# Be sure to restart your server when you modify this file. - # Define an application-wide content security policy. # See the Securing Rails Applications Guide for more information: # https://guides.rubyonrails.org/security.html#content-security-policy-header -# Rails.application.configure do -# config.content_security_policy do |policy| -# policy.default_src :self, :https -# policy.font_src :self, :https, :data -# policy.img_src :self, :https, :data -# policy.object_src :none -# policy.script_src :self, :https -# policy.style_src :self, :https -# # Specify URI for violation reports -# # policy.report_uri "/csp-violation-report-endpoint" -# end -# -# # Generate session nonces for permitted importmap and inline scripts -# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } -# config.content_security_policy_nonce_directives = %w(script-src) -# -# # Report violations without enforcing the policy. -# # config.content_security_policy_report_only = true -# end +Rails.application.configure do + config.content_security_policy do |policy| + policy.default_src :self, :https + policy.font_src :self, :https, :data + policy.img_src :self, :https, :data + policy.object_src :none + policy.script_src :self, :https + policy.style_src :self, :https, :unsafe_inline + policy.connect_src :self, :https + end + + # Generate session nonces for permitted importmap and inline scripts + config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } + config.content_security_policy_nonce_directives = %w[script-src] + + # Report violations without enforcing the policy initially. + # Remove this line once the policy has been verified in production. + config.content_security_policy_report_only = true +end From 36c75f0e994f7f46645e5f4de654e204f2f8f377 Mon Sep 17 00:00:00 2001 From: AgentKush Date: Sat, 4 Apr 2026 02:07:46 +0100 Subject: [PATCH 03/25] Add nil guard to Tool#filename to prevent NoMethodError Tool#filename calls url.split("/") without checking if url is nil. When a Firestore document lacks a fileURL field, url is nil and calling .split on it raises NoMethodError, crashing the tools index page. Add a nil guard that returns nil early when url is absent, and use URI parsing for consistency with how Mod#filename handles URLs. Fixes the medium-severity Tool#filename crash reported in #76. Co-Authored-By: Claude Opus 4.6 --- app/models/tool.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/models/tool.rb b/app/models/tool.rb index 1db43fb..732493b 100644 --- a/app/models/tool.rb +++ b/app/models/tool.rb @@ -43,7 +43,9 @@ def self.expire_cache end def filename - url.split("/").last + return nil unless url + + URI(url).path.split("/").last end def name_slug From a04a12ac23cc1a218402d4514b2e275c1b1afd3e Mon Sep 17 00:00:00 2001 From: AgentKush Date: Sat, 4 Apr 2026 02:07:55 +0100 Subject: [PATCH 04/25] Add nil guard to author_slug to prevent NoMethodError on index page The author_slug method in the Displayable concern calls author.parameterize without checking if author is nil. If a single Firestore document lacks an author field, it raises NoMethodError and can crash the entire mods or tools index page since author_slug is called during rendering of every record. Return "unknown" as a safe fallback slug when author is nil, keeping the page rendering and producing a valid URL segment. Fixes the medium-severity author_slug crash reported in #76. Co-Authored-By: Claude Opus 4.6 --- app/models/concerns/displayable.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/models/concerns/displayable.rb b/app/models/concerns/displayable.rb index 14dc06e..5abff8b 100644 --- a/app/models/concerns/displayable.rb +++ b/app/models/concerns/displayable.rb @@ -19,6 +19,8 @@ def details end def author_slug + return "unknown" unless author + author.parameterize end From 2df3a31851f4dfa31ddbe745f671653c81855158 Mon Sep 17 00:00:00 2001 From: AgentKush Date: Sat, 4 Apr 2026 17:53:39 +0100 Subject: [PATCH 05/25] Fix Tool#filename to match Mod#filename safe approach Use string splitting instead of URI() to avoid URI::InvalidURIError on malformed Firestore URLs, matching the safer approach already used in Mod#filename (commit 2b66be7). Co-Authored-By: Claude Opus 4.6 --- .ruby-version | 2 +- app/models/tool.rb | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.ruby-version b/.ruby-version index 7921bd0..7bcbb38 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.4.8 +3.4.9 diff --git a/app/models/tool.rb b/app/models/tool.rb index 732493b..06c3344 100644 --- a/app/models/tool.rb +++ b/app/models/tool.rb @@ -43,9 +43,9 @@ def self.expire_cache end def filename - return nil unless url + return if url.blank? - URI(url).path.split("/").last + url.split("?").first.split("/").last end def name_slug From e79cca66bf7f5b4211cb5ad21567c54af0264c51 Mon Sep 17 00:00:00 2001 From: AgentKush Date: Sat, 4 Apr 2026 10:56:54 +0100 Subject: [PATCH 06/25] Add dark/light theme toggle to header nav --- app/views/layouts/_header.html.erb | 5 +++- app/views/layouts/application.html.erb | 36 ++++++++++++++++++++++++++ config/tailwind.config.js | 1 + 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/app/views/layouts/_header.html.erb b/app/views/layouts/_header.html.erb index 369e3e1..2073801 100644 --- a/app/views/layouts/_header.html.erb +++ b/app/views/layouts/_header.html.erb @@ -3,7 +3,7 @@ <%= image_tag "daedalus-logo.png", width: "64", alt: "Daedalus Logo" %> <%= link_to "Project Daedalus", home_path, class: "drop-shadow-md text-2xl font-bold leading-7 no-underline text-icarus-500 sm:truncate sm:text-4xl sm:py-1 sm:tracking-tight" %> -
+
@@ -13,5 +13,8 @@ +
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 022e8e6..97ba177 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -15,6 +15,18 @@ <%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %> <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> <%= javascript_importmap_tags %> + @@ -27,5 +39,29 @@ <%= yield %> + + diff --git a/config/tailwind.config.js b/config/tailwind.config.js index 913cb8b..2beaa15 100644 --- a/config/tailwind.config.js +++ b/config/tailwind.config.js @@ -1,6 +1,7 @@ const defaultTheme = require("tailwindcss/defaultTheme") module.exports = { + darkMode: "class", content: [ "./public/*.html", "./app/helpers/**/*.rb", From 70bcd09787bb9c4d0bba4995e4d0173197efcc42 Mon Sep 17 00:00:00 2001 From: AgentKush Date: Sat, 4 Apr 2026 17:55:36 +0100 Subject: [PATCH 07/25] Respect OS dark mode preference when no saved theme exists When switching Tailwind from darkMode: "media" to "class", the OS preference is no longer automatically respected. This adds a matchMedia check as fallback when no localStorage preference is saved, preserving the original behavior for first-time visitors. Co-Authored-By: Claude Opus 4.6 --- .ruby-version | 2 +- app/views/layouts/application.html.erb | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.ruby-version b/.ruby-version index 7921bd0..7bcbb38 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.4.8 +3.4.9 diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 97ba177..4604ac4 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -16,15 +16,18 @@ <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> <%= javascript_importmap_tags %> From bb586a8be1476d9be29e383f37380ea96f3ed64b Mon Sep 17 00:00:00 2001 From: AgentKush Date: Sat, 4 Apr 2026 03:00:08 +0100 Subject: [PATCH 08/25] Add pagination to mods listing Implements array-based pagination for the mods index when displaying all mods. Search results and author-filtered views show all matches without pagination, as requested in issue #55. - Add PaginationHelper with page windowing and ellipsis support - Add _pagination.html.erb partial with Prev/Next and page numbers - Paginate at 20 mods per page (configurable via DEFAULT_PER_PAGE) - Show "page X of Y" counter when paginated - Styled with existing Tailwind classes for light/dark mode --- app/controllers/mods_controller.rb | 20 ++++++++- app/helpers/pagination_helper.rb | 68 +++++++++++++++++++++++++++++ app/views/mods/_mods.html.erb | 8 +++- app/views/mods/_pagination.html.erb | 49 +++++++++++++++++++++ 4 files changed, 141 insertions(+), 4 deletions(-) create mode 100644 app/helpers/pagination_helper.rb create mode 100644 app/views/mods/_pagination.html.erb diff --git a/app/controllers/mods_controller.rb b/app/controllers/mods_controller.rb index 41858dd..b30663d 100644 --- a/app/controllers/mods_controller.rb +++ b/app/controllers/mods_controller.rb @@ -1,12 +1,19 @@ # frozen_string_literal: true class ModsController < ApplicationController + include PaginationHelper + before_action :authors, only: %i[index show] before_action :mods, only: %i[index show] before_action :set_session, only: %i[index] def index - @mods = find_mods_by_author(sanitize(params[:author])) if params[:author].present? + @filtered = false + + if params[:author].present? + @mods = find_mods_by_author(sanitize(params[:author])) + @filtered = true + end # If we're given a mod ID, try to find it and redirect to the mod's page if @mods.empty? && params[:author].present? @@ -15,7 +22,10 @@ def index end # Perform a search if we have a query - @mods = find_mods(sanitize(params[:query])) if params[:query].present? + if params[:query].present? + @mods = find_mods(sanitize(params[:query])) + @filtered = true + end # Sort the mods if we have a sort key # Disabled for now, as it's redundant with the search @@ -23,6 +33,12 @@ def index @total_mods = @mods.size + # Paginate only when listing all mods (no search query or author filter) + unless @filtered + @pagination = paginate_array(@mods, page: params[:page]) + @mods = @pagination.items + end + if turbo_frame_request? render partial: "mods", locals: { mods: @mods } else diff --git a/app/helpers/pagination_helper.rb b/app/helpers/pagination_helper.rb new file mode 100644 index 0000000..6cfc6e8 --- /dev/null +++ b/app/helpers/pagination_helper.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module PaginationHelper + DEFAULT_PER_PAGE = 20 + + # Paginates an array and returns the current page slice + def paginate_array(collection, page:, per_page: DEFAULT_PER_PAGE) + page = [page.to_i, 1].max + total = collection.size + total_pages = (total / per_page.to_f).ceil + page = [page, total_pages].min if total_pages > 0 + + offset = (page - 1) * per_page + items = collection[offset, per_page] || [] + + PaginationResult.new(items: items, current_page: page, total_pages: total_pages, total_count: total, per_page: per_page) + end + + class PaginationResult + attr_reader :items, :current_page, :total_pages, :total_count, :per_page + + def initialize(items:, current_page:, total_pages:, total_count:, per_page:) + @items = items + @current_page = current_page + @total_pages = total_pages + @total_count = total_count + @per_page = per_page + end + + def first_page? + current_page <= 1 + end + + def last_page? + current_page >= total_pages + end + + def paginated? + total_pages > 1 + end + + def previous_page + current_page - 1 unless first_page? + end + + def next_page + current_page + 1 unless last_page? + end + + # Returns an array of page numbers with ellipsis markers (nil) for gaps + def page_range(window: 2) + return (1..total_pages).to_a if total_pages <= (window * 2) + 5 + + pages = [] + pages << 1 + + left = [current_page - window, 2].max + right = [current_page + window, total_pages - 1].min + + pages << nil if left > 2 + (left..right).each { |p| pages << p } + pages << nil if right < total_pages - 1 + + pages << total_pages + pages + end + end +end diff --git a/app/views/mods/_mods.html.erb b/app/views/mods/_mods.html.erb index 40b7b2d..08e87b6 100644 --- a/app/views/mods/_mods.html.erb +++ b/app/views/mods/_mods.html.erb @@ -1,6 +1,9 @@ <%= turbo_frame_tag "mods" do %>
-
+
+
<%= @total_mods %> mods<%= " (page #{@pagination.current_page} of #{@pagination.total_pages})" if @pagination&.paginated? %>
+
+
@@ -23,5 +26,6 @@
+ <%= render "pagination" %>
-<% end %> \ No newline at end of file +<% end %> diff --git a/app/views/mods/_pagination.html.erb b/app/views/mods/_pagination.html.erb new file mode 100644 index 0000000..4c493f0 --- /dev/null +++ b/app/views/mods/_pagination.html.erb @@ -0,0 +1,49 @@ +<% if @pagination&.paginated? %> + +<% end %> From 32451bdd67e401a55c200d0560ec1d43f9d6d62f Mon Sep 17 00:00:00 2001 From: AgentKush Date: Sat, 4 Apr 2026 17:54:58 +0100 Subject: [PATCH 09/25] Add test coverage for PaginationHelper and PaginationResult Covers paginate_array edge cases (empty collection, page clamping, custom per_page) and PaginationResult methods (first/last page, navigation, page_range with ellipsis). Co-Authored-By: Claude Opus 4.6 --- .ruby-version | 2 +- spec/helpers/pagination_helper_spec.rb | 123 +++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 spec/helpers/pagination_helper_spec.rb diff --git a/.ruby-version b/.ruby-version index 7921bd0..7bcbb38 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.4.8 +3.4.9 diff --git a/spec/helpers/pagination_helper_spec.rb b/spec/helpers/pagination_helper_spec.rb new file mode 100644 index 0000000..62f5a6b --- /dev/null +++ b/spec/helpers/pagination_helper_spec.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaginationHelper do + include PaginationHelper + + let(:items) { (1..50).to_a } + + describe "#paginate_array" do + it "returns the first page by default" do + result = paginate_array(items, page: 1) + expect(result.items).to eq((1..20).to_a) + expect(result.current_page).to eq(1) + expect(result.total_pages).to eq(3) + expect(result.total_count).to eq(50) + end + + it "returns the correct page slice" do + result = paginate_array(items, page: 2) + expect(result.items).to eq((21..40).to_a) + expect(result.current_page).to eq(2) + end + + it "returns the last page with remaining items" do + result = paginate_array(items, page: 3) + expect(result.items).to eq((41..50).to_a) + end + + it "clamps page to 1 when given 0 or negative" do + result = paginate_array(items, page: 0) + expect(result.current_page).to eq(1) + + result = paginate_array(items, page: -5) + expect(result.current_page).to eq(1) + end + + it "clamps page to last page when exceeding total" do + result = paginate_array(items, page: 999) + expect(result.current_page).to eq(3) + expect(result.items).to eq((41..50).to_a) + end + + it "respects custom per_page" do + result = paginate_array(items, page: 1, per_page: 10) + expect(result.items).to eq((1..10).to_a) + expect(result.total_pages).to eq(5) + end + + it "handles empty collection" do + result = paginate_array([], page: 1) + expect(result.items).to eq([]) + expect(result.total_pages).to eq(0) + expect(result.total_count).to eq(0) + end + end + + describe PaginationHelper::PaginationResult do + subject(:result) do + PaginationHelper::PaginationResult.new( + items: [], current_page: current_page, total_pages: 5, total_count: 100, per_page: 20 + ) + end + + context "on the first page" do + let(:current_page) { 1 } + + it { is_expected.to be_first_page } + it { is_expected.not_to be_last_page } + it { is_expected.to be_paginated } + it { expect(result.previous_page).to be_nil } + it { expect(result.next_page).to eq(2) } + end + + context "on a middle page" do + let(:current_page) { 3 } + + it { is_expected.not_to be_first_page } + it { is_expected.not_to be_last_page } + it { expect(result.previous_page).to eq(2) } + it { expect(result.next_page).to eq(4) } + end + + context "on the last page" do + let(:current_page) { 5 } + + it { is_expected.not_to be_first_page } + it { is_expected.to be_last_page } + it { expect(result.previous_page).to eq(4) } + it { expect(result.next_page).to be_nil } + end + + context "with a single page" do + subject(:result) do + PaginationHelper::PaginationResult.new( + items: [], current_page: 1, total_pages: 1, total_count: 5, per_page: 20 + ) + end + + it { is_expected.not_to be_paginated } + end + + describe "#page_range" do + it "returns all pages when total is small" do + result = PaginationHelper::PaginationResult.new( + items: [], current_page: 1, total_pages: 5, total_count: 100, per_page: 20 + ) + expect(result.page_range).to eq([1, 2, 3, 4, 5]) + end + + it "includes ellipsis for large page counts" do + result = PaginationHelper::PaginationResult.new( + items: [], current_page: 10, total_pages: 20, total_count: 400, per_page: 20 + ) + range = result.page_range + expect(range.first).to eq(1) + expect(range.last).to eq(20) + expect(range).to include(nil) # ellipsis markers + expect(range).to include(10) # current page + end + end + end +end From d4d482f358a9dc2c46843d49e6ffb0af65677bdb Mon Sep 17 00:00:00 2001 From: AgentKush Date: Sat, 4 Apr 2026 02:08:19 +0100 Subject: [PATCH 10/25] Remove .env from version control and add .env.example template The .gitignore only had .env.* (matching .env.local, .env.production, etc.) but not .env itself, so the base .env file was committed and tracked. While it currently contains non-secret config (project IDs, bucket names), tracking .env files is a security risk as developers may add secrets to it later. Changes: - Add .env to .gitignore so it is no longer tracked - Remove .env from git index (file stays on disk for existing devs) - Add .env.example template so new developers know which vars to set - Whitelist .env.example in .gitignore so the template is tracked Fixes the low-severity .env tracking bug reported in #76. Co-Authored-By: Claude Opus 4.6 --- .env | 8 -------- .env.example | 6 ++++++ .gitignore | 2 ++ 3 files changed, 8 insertions(+), 8 deletions(-) delete mode 100644 .env create mode 100644 .env.example diff --git a/.env b/.env deleted file mode 100644 index b8a9baa..0000000 --- a/.env +++ /dev/null @@ -1,8 +0,0 @@ -# PRODUCTION_DB_NAME=project_daedalus_production # Legacy Cloud SQL/Cloud Run — no longer used -# PRODUCTION_DB_USER=daedalus # Legacy Cloud SQL/Cloud Run — no longer used -# CLOUD_SQL_CONNECTION_NAME=projectdaedalus-fb09f:us-central1:project-daedalus # Legacy Cloud SQL/Cloud Run — no longer used -GOOGLE_PROJECT_ID=projectdaedalus-fb09f -STORAGE_BUCKET_NAME=project-daedalus-public -GOOGLE_REGION=us-central1 -# GOOGLE_SERVICE_NAME=project-daedalus # Legacy Cloud SQL/Cloud Run — no longer used -# GOOGLE_INSTANCE_NAME=project-daedalus # Legacy Cloud SQL/Cloud Run — no longer used diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7a853a0 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# Copy this file to .env and fill in your values +# cp .env.example .env + +GOOGLE_PROJECT_ID=your-firebase-project-id +STORAGE_BUCKET_NAME=your-storage-bucket-name +GOOGLE_REGION=us-central1 diff --git a/.gitignore b/.gitignore index d047613..e48669d 100644 --- a/.gitignore +++ b/.gitignore @@ -38,7 +38,9 @@ /app/assets/builds/* !/app/assets/builds/.keep +.env .env.* +!.env.example # Claude Code local settings .claude/ From c96f8f07426b5078d94207c27b6260ed8b7de930 Mon Sep 17 00:00:00 2001 From: AgentKush Date: Sat, 4 Apr 2026 02:08:30 +0100 Subject: [PATCH 11/25] Use author_slug instead of raw author name in mod partial URLs The _mod.html.erb partial passes mod.author (which contains spaces and mixed case) to mod_detail_path, producing ugly URLs with encoded spaces like /mods/Donovan%20Young/some-mod. Change to mod.author_slug which produces clean, parameterized URLs like /mods/donovan-young/some-mod, consistent with how the show action and author filtering already work. Fixes the low-severity ugly URL bug reported in #76. Co-Authored-By: Claude Opus 4.6 --- app/views/mods/_mod.html.erb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/views/mods/_mod.html.erb b/app/views/mods/_mod.html.erb index e9482c0..194e193 100644 --- a/app/views/mods/_mod.html.erb +++ b/app/views/mods/_mod.html.erb @@ -1,6 +1,6 @@ + data-mods-path-param="<%= mod_detail_path(author: mod.author_slug, slug: mod.slug) %>"> <%= mod.name %> <% if type = mod.preferred_type %> @@ -25,5 +25,6 @@ <% end %> +<<<<<<< HEAD <%= truncate(mod.description.to_s, length: 120) %> - \ No newline at end of file + From 4439ce2b44f2e81d1d9ec1ea25bfc5ebc14ab220 Mon Sep 17 00:00:00 2001 From: AgentKush Date: Sat, 4 Apr 2026 02:08:38 +0100 Subject: [PATCH 12/25] Increase search debounce from 200ms to 400ms to reduce requests The search input debounce was set to 200ms, firing a Turbo Frame request on nearly every keystroke. Increase to 400ms which still feels responsive but significantly reduces unnecessary requests and Firestore reads during active typing. Fixes the low-severity search debounce bug reported in #76. Co-Authored-By: Claude Opus 4.6 --- app/javascript/controllers/mods_controller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/controllers/mods_controller.js b/app/javascript/controllers/mods_controller.js index a9f54cc..63ba14c 100644 --- a/app/javascript/controllers/mods_controller.js +++ b/app/javascript/controllers/mods_controller.js @@ -37,7 +37,7 @@ export default class extends Controller { clearTimeout(this.timeout) this.timeout = setTimeout(() => { event.target.form.requestSubmit(); - }, 200) + }, 400) } submit(event) { From f1413b8333262f2048b0902778324d4d26e56d7e Mon Sep 17 00:00:00 2001 From: AgentKush Date: Sat, 4 Apr 2026 02:08:50 +0100 Subject: [PATCH 13/25] Memoize markdown renderer to avoid recreating objects per call The markdown helper created new CodeRayify and Redcarpet::Markdown instances on every call. On the mods index this means allocating these objects once per mod with a README. Extract into a memoized private method so objects are created once per request and reused for all subsequent renders. Fixes the low-severity markdown renderer performance bug in #76. Co-Authored-By: Claude Opus 4.6 --- app/helpers/application_helper.rb | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index c4956b4..0d38801 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -14,15 +14,23 @@ def block_code(code, language) def markdown(text) return if text.blank? - coderayified = CodeRayify.new(filter_html: true, hard_wrap: true) - - options = { - fenced_code_blocks: true, - no_intra_emphasis: true, - autolink: true, - lax_html_blocks: true - } - markdown_to_html = Redcarpet::Markdown.new(coderayified, options) - sanitize(markdown_to_html.render(text)) + sanitize(markdown_renderer.render(text)) + end + + private + + def markdown_renderer + @markdown_renderer ||= begin + coderayified = CodeRayify.new(filter_html: true, hard_wrap: true) + + options = { + fenced_code_blocks: true, + no_intra_emphasis: true, + autolink: true, + lax_html_blocks: true + } + + Redcarpet::Markdown.new(coderayified, options) + end end end From 811834634e9d5145137cc43c3b07eb8271099be4 Mon Sep 17 00:00:00 2001 From: AgentKush Date: Sat, 4 Apr 2026 02:09:00 +0100 Subject: [PATCH 14/25] Enable require_master_key in production environment The require_master_key setting was commented out, meaning the app can boot in production without a master key. Without it, Rails credentials (including Firebase keyfile) can't be decrypted, causing confusing Firestore errors instead of a clear startup failure. Uncomment so production deploys fail fast if RAILS_MASTER_KEY is missing. The Kamal deploy config already provides this as a secret. Fixes the low-severity require_master_key bug reported in #76. Co-Authored-By: Claude Opus 4.6 --- config/environments/production.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/environments/production.rb b/config/environments/production.rb index 391e08d..d790609 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -20,7 +20,7 @@ # Ensures that a master key has been made available in either ENV["RAILS_MASTER_KEY"] # or in config/master.key. This key is used to decrypt credentials (and other encrypted files). - # config.require_master_key = true + config.require_master_key = true # Disable serving static files from the `/public` folder by default since # Apache or NGINX already handles this. From f306d35ab1764c5a827ae1e2e62dee8d225044fd Mon Sep 17 00:00:00 2001 From: AgentKush Date: Sat, 4 Apr 2026 02:09:12 +0100 Subject: [PATCH 15/25] Memoize Firestore client to avoid redundant connections The firestore class method creates a new Google::Cloud::Firestore client on every call. Memoize with @firestore ||= so the client is created once per class and reused. The Google Cloud Firestore client is designed to be long-lived and thread-safe. Fixes the low-severity Firestore client performance bug in #76. Co-Authored-By: Claude Opus 4.6 --- app/models/concerns/firestorable.rb | 30 +++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/app/models/concerns/firestorable.rb b/app/models/concerns/firestorable.rb index 92ebfb1..e1494b5 100644 --- a/app/models/concerns/firestorable.rb +++ b/app/models/concerns/firestorable.rb @@ -7,24 +7,26 @@ module Firestorable included do def self.firestore - credentials = Rails.application.credentials.firebase_keyfile + @firestore ||= begin + credentials = Rails.application.credentials.firebase_keyfile - if credentials.nil? - raise <<~ERROR - Firebase credentials not configured. Please add firebase_keyfile to your Rails credentials. + if credentials.nil? + raise <<~ERROR + Firebase credentials not configured. Please add firebase_keyfile to your Rails credentials. - To configure credentials, run: - EDITOR=nano rails credentials:edit + To configure credentials, run: + EDITOR=nano rails credentials:edit - Then add: - firebase_keyfile: - type: service_account - project_id: your-project-id - # ... other Firebase credentials - ERROR - end + Then add: + firebase_keyfile: + type: service_account + project_id: your-project-id + # ... other Firebase credentials + ERROR + end - Google::Cloud::Firestore.new(credentials: credentials.to_h) + Google::Cloud::Firestore.new(credentials: credentials.to_h) + end end end end From 30ae0e3be720eb316bda26de5f5c907ac8732a4e Mon Sep 17 00:00:00 2001 From: AgentKush Date: Sat, 4 Apr 2026 17:54:31 +0100 Subject: [PATCH 16/25] Fix Firestore memoization leaking test doubles across examples Reset class-level @firestore instance variable after each test to prevent RSpec doubles from persisting via Firestorable's @firestore ||= memoization pattern. This fixes 8 test failures where leaked doubles caused errors in subsequent examples. Co-Authored-By: Claude Opus 4.6 --- .ruby-version | 2 +- spec/support/firestore.rb | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.ruby-version b/.ruby-version index 7921bd0..7bcbb38 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.4.8 +3.4.9 diff --git a/spec/support/firestore.rb b/spec/support/firestore.rb index f1859cc..869e40d 100644 --- a/spec/support/firestore.rb +++ b/spec/support/firestore.rb @@ -15,4 +15,16 @@ ) end end + + # Reset memoized Firestore clients after each test to prevent + # RSpec test doubles from leaking across examples via the + # class-level @firestore ||= memoization in Firestorable. + config.after do + [Mod, Tool].each do |klass| + klass.instance_variable_set(:@firestore, nil) if klass.instance_variable_defined?(:@firestore) + end + if defined?(SiteContent) && SiteContent.respond_to?(:instance_variable_set) + SiteContent.instance_variable_set(:@firestore, nil) if SiteContent.instance_variable_defined?(:@firestore) + end + end end From 65778416bc2bca8e60dce0522795c2a024d77e56 Mon Sep 17 00:00:00 2001 From: AgentKush Date: Sat, 4 Apr 2026 18:33:57 +0100 Subject: [PATCH 17/25] Fix RuboCop offenses in pagination helper and specs - Use .positive? instead of > 0 - Break long line in PaginationResult.new - Use described_class instead of explicit class names in specs - Fix context descriptions to start with when/with/without Co-Authored-By: Claude Opus 4.6 --- app/helpers/pagination_helper.rb | 7 +++++-- spec/helpers/pagination_helper_spec.rb | 18 +++++++++--------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/app/helpers/pagination_helper.rb b/app/helpers/pagination_helper.rb index 6cfc6e8..cc6988a 100644 --- a/app/helpers/pagination_helper.rb +++ b/app/helpers/pagination_helper.rb @@ -8,12 +8,15 @@ def paginate_array(collection, page:, per_page: DEFAULT_PER_PAGE) page = [page.to_i, 1].max total = collection.size total_pages = (total / per_page.to_f).ceil - page = [page, total_pages].min if total_pages > 0 + page = [page, total_pages].min if total_pages.positive? offset = (page - 1) * per_page items = collection[offset, per_page] || [] - PaginationResult.new(items: items, current_page: page, total_pages: total_pages, total_count: total, per_page: per_page) + PaginationResult.new( + items: items, current_page: page, total_pages: total_pages, + total_count: total, per_page: per_page + ) end class PaginationResult diff --git a/spec/helpers/pagination_helper_spec.rb b/spec/helpers/pagination_helper_spec.rb index 62f5a6b..5308667 100644 --- a/spec/helpers/pagination_helper_spec.rb +++ b/spec/helpers/pagination_helper_spec.rb @@ -3,7 +3,7 @@ require "rails_helper" RSpec.describe PaginationHelper do - include PaginationHelper + include described_class let(:items) { (1..50).to_a } @@ -55,14 +55,14 @@ end end - describe PaginationHelper::PaginationResult do + describe described_class::PaginationResult do subject(:result) do - PaginationHelper::PaginationResult.new( + described_class.new( items: [], current_page: current_page, total_pages: 5, total_count: 100, per_page: 20 ) end - context "on the first page" do + context "when on the first page" do let(:current_page) { 1 } it { is_expected.to be_first_page } @@ -72,7 +72,7 @@ it { expect(result.next_page).to eq(2) } end - context "on a middle page" do + context "when on a middle page" do let(:current_page) { 3 } it { is_expected.not_to be_first_page } @@ -81,7 +81,7 @@ it { expect(result.next_page).to eq(4) } end - context "on the last page" do + context "when on the last page" do let(:current_page) { 5 } it { is_expected.not_to be_first_page } @@ -92,7 +92,7 @@ context "with a single page" do subject(:result) do - PaginationHelper::PaginationResult.new( + described_class.new( items: [], current_page: 1, total_pages: 1, total_count: 5, per_page: 20 ) end @@ -102,14 +102,14 @@ describe "#page_range" do it "returns all pages when total is small" do - result = PaginationHelper::PaginationResult.new( + result = described_class.new( items: [], current_page: 1, total_pages: 5, total_count: 100, per_page: 20 ) expect(result.page_range).to eq([1, 2, 3, 4, 5]) end it "includes ellipsis for large page counts" do - result = PaginationHelper::PaginationResult.new( + result = described_class.new( items: [], current_page: 10, total_pages: 20, total_count: 400, per_page: 20 ) range = result.page_range From b6614e10f4f7f86c0c5ff1fde073b61d88f5c791 Mon Sep 17 00:00:00 2001 From: AgentKush Date: Sat, 4 Apr 2026 18:34:28 +0100 Subject: [PATCH 18/25] Update _mod view spec to expect author_slug in path param The template now uses mod.author_slug instead of mod.author for cleaner URLs, so the test expectation needs to match. Co-Authored-By: Claude Opus 4.6 --- spec/views/mods/_mod.html.erb_spec.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/views/mods/_mod.html.erb_spec.rb b/spec/views/mods/_mod.html.erb_spec.rb index 5e5a94b..2afc838 100644 --- a/spec/views/mods/_mod.html.erb_spec.rb +++ b/spec/views/mods/_mod.html.erb_spec.rb @@ -51,8 +51,8 @@ it "includes correct mod_detail_path in data attribute" do render partial: "mods/mod", locals: { mod: mod } - # Path uses mod.author (which gets URL encoded), not author_slug - expect(rendered).to include('data-mods-path-param="/mods/Test%20Author/test-mod"') + # Path uses mod.author_slug (parameterized) for cleaner URLs + expect(rendered).to include('data-mods-path-param="/mods/test-author/test-mod"') end it "triggers download Stimulus action on button click" do From b047264677f20d24510252c08aa6c3ccaeaeac6d Mon Sep 17 00:00:00 2001 From: "Donovan C. Young" Date: Sat, 4 Apr 2026 13:37:23 -0400 Subject: [PATCH 19/25] Add .serena/ and .playwright-mcp/ to .gitignore These are local MCP plugin directories that shouldn't be tracked. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 6 ++++ .serena/.gitignore | 1 - .serena/project.yml | 87 --------------------------------------------- 3 files changed, 6 insertions(+), 88 deletions(-) delete mode 100644 .serena/.gitignore delete mode 100644 .serena/project.yml diff --git a/.gitignore b/.gitignore index d047613..30a89bc 100644 --- a/.gitignore +++ b/.gitignore @@ -42,4 +42,10 @@ # Claude Code local settings .claude/ + +# Serena MCP plugin +.serena/ + +# Playwright MCP plugin +.playwright-mcp/ firebase-debug.log diff --git a/.serena/.gitignore b/.serena/.gitignore deleted file mode 100644 index 14d86ad..0000000 --- a/.serena/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/cache diff --git a/.serena/project.yml b/.serena/project.yml deleted file mode 100644 index 8206bd1..0000000 --- a/.serena/project.yml +++ /dev/null @@ -1,87 +0,0 @@ -# list of languages for which language servers are started; choose from: -# al bash clojure cpp csharp csharp_omnisharp -# dart elixir elm erlang fortran fsharp -# go groovy haskell java julia kotlin -# lua markdown nix pascal perl php -# powershell python python_jedi r rego ruby -# ruby_solargraph rust scala swift terraform toml -# typescript typescript_vts yaml zig -# Note: -# - For C, use cpp -# - For JavaScript, use typescript -# - For Free Pascal / Lazarus, use pascal -# Special requirements: -# - csharp: Requires the presence of a .sln file in the project folder. -# - pascal: Requires Free Pascal Compiler (fpc) and optionally Lazarus. -# When using multiple languages, the first language server that supports a given file will be used for that file. -# The first language is the default language and the respective language server will be used as a fallback. -# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. -languages: -- ruby - -# the encoding used by text files in the project -# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings -encoding: "utf-8" - -# whether to use the project's gitignore file to ignore files -# Added on 2025-04-07 -ignore_all_files_in_gitignore: true - -# list of additional paths to ignore -# same syntax as gitignore, so you can use * and ** -# Was previously called `ignored_dirs`, please update your config if you are using that. -# Added (renamed) on 2025-04-07 -ignored_paths: [] - -# whether the project is in read-only mode -# If set to true, all editing tools will be disabled and attempts to use them will result in an error -# Added on 2025-04-18 -read_only: false - -# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details. -# Below is the complete list of tools for convenience. -# To make sure you have the latest list of tools, and to view their descriptions, -# execute `uv run scripts/print_tool_overview.py`. -# -# * `activate_project`: Activates a project by name. -# * `check_onboarding_performed`: Checks whether project onboarding was already performed. -# * `create_text_file`: Creates/overwrites a file in the project directory. -# * `delete_lines`: Deletes a range of lines within a file. -# * `delete_memory`: Deletes a memory from Serena's project-specific memory store. -# * `execute_shell_command`: Executes a shell command. -# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. -# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). -# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). -# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. -# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. -# * `initial_instructions`: Gets the initial instructions for the current project. -# Should only be used in settings where the system prompt cannot be set, -# e.g. in clients you have no control over, like Claude Desktop. -# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. -# * `insert_at_line`: Inserts content at a given line in a file. -# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. -# * `list_dir`: Lists files and directories in the given directory (optionally with recursion). -# * `list_memories`: Lists memories in Serena's project-specific memory store. -# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). -# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). -# * `read_file`: Reads a file within the project directory. -# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. -# * `remove_project`: Removes a project from the Serena configuration. -# * `replace_lines`: Replaces a range of lines within a file with new content. -# * `replace_symbol_body`: Replaces the full definition of a symbol. -# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. -# * `search_for_pattern`: Performs a search for a pattern in the project. -# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. -# * `switch_modes`: Activates modes by providing a list of their names -# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. -# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. -# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. -# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. -excluded_tools: [] - -# initial prompt for the project. It will always be given to the LLM upon activating the project -# (contrary to the memories, which are loaded on demand). -initial_prompt: "" - -project_name: "project_daedalus" -included_optional_tools: [] From 4c163a51916794182530a3ffbe60460ad43bd552 Mon Sep 17 00:00:00 2001 From: AgentKush Date: Sat, 4 Apr 2026 18:41:56 +0100 Subject: [PATCH 20/25] Move theme toggle to Stimulus controller for scoped JS Replaces inline toggleTheme() and DOMContentLoaded listener with a Stimulus theme_controller.js. Uses data-action and data-target attributes instead of global functions and getElementById, preventing potential conflicts with other scripts. The early-load FOUC prevention script in is kept since Stimulus connects after DOMContentLoaded. Co-Authored-By: Claude Opus 4.6 --- .../controllers/theme_controller.js | 29 +++++++++++++++++++ app/views/layouts/_header.html.erb | 8 +++-- app/views/layouts/application.html.erb | 23 --------------- 3 files changed, 34 insertions(+), 26 deletions(-) create mode 100644 app/javascript/controllers/theme_controller.js diff --git a/app/javascript/controllers/theme_controller.js b/app/javascript/controllers/theme_controller.js new file mode 100644 index 0000000..174fb30 --- /dev/null +++ b/app/javascript/controllers/theme_controller.js @@ -0,0 +1,29 @@ +import { Controller } from "@hotwired/stimulus" + +// Manages dark/light theme toggle with localStorage persistence. +// Falls back to OS preference when no saved preference exists. +export default class extends Controller { + static targets = ["icon"] + + connect() { + this.updateIcon() + } + + toggle() { + const html = document.documentElement + if (html.classList.contains("dark")) { + html.classList.remove("dark") + localStorage.setItem("theme", "light") + } else { + html.classList.add("dark") + localStorage.setItem("theme", "dark") + } + this.updateIcon() + } + + updateIcon() { + if (!this.hasIconTarget) return + const isDark = document.documentElement.classList.contains("dark") + this.iconTarget.innerHTML = isDark ? "\uD83C\uDF19" : "\u2600\uFE0F" + } +} diff --git a/app/views/layouts/_header.html.erb b/app/views/layouts/_header.html.erb index 2073801..3ae9325 100644 --- a/app/views/layouts/_header.html.erb +++ b/app/views/layouts/_header.html.erb @@ -13,8 +13,10 @@ - +
+ +
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 4604ac4..4898c3b 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -43,28 +43,5 @@ - From e025efd5cc647f9327e647643089edc342af4610 Mon Sep 17 00:00:00 2001 From: AgentKush Date: Sat, 4 Apr 2026 18:46:53 +0100 Subject: [PATCH 21/25] Fix RuboCop SoleNestedConditional in firestore support Merge nested conditional into outer if to satisfy RuboCop Style/SoleNestedConditional rule. Co-Authored-By: Claude Opus 4.6 --- spec/support/firestore.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/spec/support/firestore.rb b/spec/support/firestore.rb index 869e40d..1f7d8a9 100644 --- a/spec/support/firestore.rb +++ b/spec/support/firestore.rb @@ -23,8 +23,9 @@ [Mod, Tool].each do |klass| klass.instance_variable_set(:@firestore, nil) if klass.instance_variable_defined?(:@firestore) end - if defined?(SiteContent) && SiteContent.respond_to?(:instance_variable_set) - SiteContent.instance_variable_set(:@firestore, nil) if SiteContent.instance_variable_defined?(:@firestore) + if defined?(SiteContent) && SiteContent.respond_to?(:instance_variable_set) && + SiteContent.instance_variable_defined?(:@firestore) + SiteContent.instance_variable_set(:@firestore, nil) end end end From fb7f18e1ccd83c7f254f6746db4961465ee02ac5 Mon Sep 17 00:00:00 2001 From: AgentKush Date: Sat, 4 Apr 2026 18:47:24 +0100 Subject: [PATCH 22/25] Refactor index action to reduce complexity Extract filter_by_author, filter_by_query, paginate_mods, and render_index private methods to bring ABC size and method length below RuboCop thresholds. Co-Authored-By: Claude Opus 4.6 --- app/controllers/mods_controller.rb | 70 ++++++++++++++++-------------- 1 file changed, 38 insertions(+), 32 deletions(-) diff --git a/app/controllers/mods_controller.rb b/app/controllers/mods_controller.rb index b30663d..025499d 100644 --- a/app/controllers/mods_controller.rb +++ b/app/controllers/mods_controller.rb @@ -10,40 +10,13 @@ class ModsController < ApplicationController def index @filtered = false - if params[:author].present? - @mods = find_mods_by_author(sanitize(params[:author])) - @filtered = true - end - - # If we're given a mod ID, try to find it and redirect to the mod's page - if @mods.empty? && params[:author].present? - @mod = mods.find { |mod| mod.id == params[:author] } - return redirect_to mod_detail_path(author: @mod.author_slug, slug: @mod.slug) if @mod.present? - end - - # Perform a search if we have a query - if params[:query].present? - @mods = find_mods(sanitize(params[:query])) - @filtered = true - end - - # Sort the mods if we have a sort key - # Disabled for now, as it's redundant with the search - # params[:sort].present? && Mod::SORTKEYS.include?(params[:sort]) && @mods.sort_by! { |mod| [mod.send(sanitize(params[:sort])), mod.name] } + filter_by_author + return if performed? + filter_by_query @total_mods = @mods.size - - # Paginate only when listing all mods (no search query or author filter) - unless @filtered - @pagination = paginate_array(@mods, page: params[:page]) - @mods = @pagination.items - end - - if turbo_frame_request? - render partial: "mods", locals: { mods: @mods } - else - render :index - end + paginate_mods unless @filtered + render_index end def show @@ -62,6 +35,39 @@ def show private + def filter_by_author + return unless params[:author].present? + + @mods = find_mods_by_author(sanitize(params[:author])) + @filtered = true + + # If no mods found by author slug, try to find by mod ID and redirect + return unless @mods.empty? + + @mod = mods.find { |mod| mod.id == params[:author] } + redirect_to mod_detail_path(author: @mod.author_slug, slug: @mod.slug) if @mod.present? + end + + def filter_by_query + return unless params[:query].present? + + @mods = find_mods(sanitize(params[:query])) + @filtered = true + end + + def paginate_mods + @pagination = paginate_array(@mods, page: params[:page]) + @mods = @pagination.items + end + + def render_index + if turbo_frame_request? + render partial: "mods", locals: { mods: @mods } + else + render :index + end + end + def find_mods(query) # Escape regex special characters to prevent injection escaped_query = Regexp.escape(query) From 4591a386a2194963d9e21bb7434deba578e5b68e Mon Sep 17 00:00:00 2001 From: AgentKush Date: Sat, 4 Apr 2026 18:53:58 +0100 Subject: [PATCH 23/25] Use Rails/Blank guard clauses in filter methods Replace `return unless params[:x].present?` with `return if params[:x].blank?` per Rails/Blank cop preference. Co-Authored-By: Claude Opus 4.6 --- app/controllers/mods_controller.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controllers/mods_controller.rb b/app/controllers/mods_controller.rb index 025499d..e0a2bbc 100644 --- a/app/controllers/mods_controller.rb +++ b/app/controllers/mods_controller.rb @@ -36,7 +36,7 @@ def show private def filter_by_author - return unless params[:author].present? + return if params[:author].blank? @mods = find_mods_by_author(sanitize(params[:author])) @filtered = true @@ -49,7 +49,7 @@ def filter_by_author end def filter_by_query - return unless params[:query].present? + return if params[:query].blank? @mods = find_mods(sanitize(params[:query])) @filtered = true From c50a4ea732bd08487ef0a8cea4faab13e51109bc Mon Sep 17 00:00:00 2001 From: "Donovan C. Young" Date: Sat, 4 Apr 2026 17:43:26 -0400 Subject: [PATCH 24/25] Fix stray merge conflict marker in _mod partial Co-Authored-By: Claude Opus 4.6 (1M context) --- app/views/mods/_mod.html.erb | 1 - 1 file changed, 1 deletion(-) diff --git a/app/views/mods/_mod.html.erb b/app/views/mods/_mod.html.erb index 194e193..c82b217 100644 --- a/app/views/mods/_mod.html.erb +++ b/app/views/mods/_mod.html.erb @@ -25,6 +25,5 @@ <% end %> -<<<<<<< HEAD <%= truncate(mod.description.to_s, length: 120) %> From 3235e747b130b9da54efde518331c3d194d7aaa3 Mon Sep 17 00:00:00 2001 From: AgentKush Date: Sun, 5 Apr 2026 01:09:53 +0100 Subject: [PATCH 25/25] Fix mobile layout for mods table and pagination Table: - Remove fixed w-1/5 on Name column at mobile breakpoint so it fills available space when Author/Version/Description are hidden - Set Download column to w-auto on mobile, w-28 at sm+ - Show only file type (PAK/EXMODZ) on mobile, full "Download PAK" at sm+ - Reduce cell padding on mobile (p-2 -> sm:p-3) - Add whitespace-nowrap on download cell to prevent button wrapping Pagination: - Stack "Showing X to Y" and page controls vertically on mobile, side-by-side at sm+ (flex-col -> sm:flex-row) - Use flex-wrap + justify-center on page numbers so they wrap cleanly - Hide "Prev"/"Next" labels on mobile, keep chevrons only - Reduce padding on page number buttons for small screens Co-Authored-By: Claude Opus 4.6 --- app/views/mods/_mod.html.erb | 8 ++++---- app/views/mods/_mods.html.erb | 6 +++--- app/views/mods/_pagination.html.erb | 22 +++++++++++----------- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/app/views/mods/_mod.html.erb b/app/views/mods/_mod.html.erb index c82b217..2832aa5 100644 --- a/app/views/mods/_mod.html.erb +++ b/app/views/mods/_mod.html.erb @@ -1,16 +1,16 @@ - <%= mod.name %> - + <%= mod.name %> + <% if type = mod.preferred_type %> <% end %> diff --git a/app/views/mods/_mods.html.erb b/app/views/mods/_mods.html.erb index 08e87b6..5147fe1 100644 --- a/app/views/mods/_mods.html.erb +++ b/app/views/mods/_mods.html.erb @@ -7,9 +7,9 @@ - - - + + + diff --git a/app/views/mods/_pagination.html.erb b/app/views/mods/_pagination.html.erb index 4c493f0..d4cd799 100644 --- a/app/views/mods/_pagination.html.erb +++ b/app/views/mods/_pagination.html.erb @@ -1,5 +1,5 @@ <% if @pagination&.paginated? %> -
NameDownloadNameDownload