Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
144047d
Fix .ruby-version to match Gemfile ruby requirement (3.4.9)
AgentKush Apr 4, 2026
1092b92
Enable Content-Security-Policy headers in report-only mode
AgentKush Apr 4, 2026
36c75f0
Add nil guard to Tool#filename to prevent NoMethodError
AgentKush Apr 4, 2026
a04a12a
Add nil guard to author_slug to prevent NoMethodError on index page
AgentKush Apr 4, 2026
2df3a31
Fix Tool#filename to match Mod#filename safe approach
AgentKush Apr 4, 2026
e79cca6
Add dark/light theme toggle to header nav
AgentKush Apr 4, 2026
70bcd09
Respect OS dark mode preference when no saved theme exists
AgentKush Apr 4, 2026
bb586a8
Add pagination to mods listing
AgentKush Apr 4, 2026
32451bd
Add test coverage for PaginationHelper and PaginationResult
AgentKush Apr 4, 2026
d4d482f
Remove .env from version control and add .env.example template
AgentKush Apr 4, 2026
c96f8f0
Use author_slug instead of raw author name in mod partial URLs
AgentKush Apr 4, 2026
4439ce2
Increase search debounce from 200ms to 400ms to reduce requests
AgentKush Apr 4, 2026
f1413b8
Memoize markdown renderer to avoid recreating objects per call
AgentKush Apr 4, 2026
8118346
Enable require_master_key in production environment
AgentKush Apr 4, 2026
f306d35
Memoize Firestore client to avoid redundant connections
AgentKush Apr 4, 2026
30ae0e3
Fix Firestore memoization leaking test doubles across examples
AgentKush Apr 4, 2026
6577841
Fix RuboCop offenses in pagination helper and specs
AgentKush Apr 4, 2026
b6614e1
Update _mod view spec to expect author_slug in path param
AgentKush Apr 4, 2026
b047264
Add .serena/ and .playwright-mcp/ to .gitignore
dyoung522 Apr 4, 2026
4c163a5
Move theme toggle to Stimulus controller for scoped JS
AgentKush Apr 4, 2026
e025efd
Fix RuboCop SoleNestedConditional in firestore support
AgentKush Apr 4, 2026
fb7f18e
Refactor index action to reduce complexity
AgentKush Apr 4, 2026
4591a38
Use Rails/Blank guard clauses in filter methods
AgentKush Apr 4, 2026
b2ab9c6
Merge pull request #106 from AgentKush/fix/ruby-version-mismatch
dyoung522 Apr 4, 2026
ca136fa
Merge pull request #80 from AgentKush/fix/medium-severity-nil-guards-…
dyoung522 Apr 4, 2026
8f187a8
Merge pull request #81 from AgentKush/fix/low-severity-cleanup-and-pe…
dyoung522 Apr 4, 2026
f5a9f09
Merge pull request #88 from AgentKush/feature/theme-toggle
dyoung522 Apr 4, 2026
80f4e98
Merge pull request #84 from AgentKush/fix/add-pagination
dyoung522 Apr 4, 2026
c50a4ea
Fix stray merge conflict marker in _mod partial
dyoung522 Apr 4, 2026
3235e74
Fix mobile layout for mods table and pagination
AgentKush Apr 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 0 additions & 8 deletions .env

This file was deleted.

6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,16 @@

/app/assets/builds/*
!/app/assets/builds/.keep
.env
.env.*
!.env.example

# Claude Code local settings
.claude/

# Serena MCP plugin
.serena/

# Playwright MCP plugin
.playwright-mcp/
firebase-debug.log
2 changes: 1 addition & 1 deletion .ruby-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.4.8
3.4.9
1 change: 0 additions & 1 deletion .serena/.gitignore

This file was deleted.

87 changes: 0 additions & 87 deletions .serena/project.yml

This file was deleted.

60 changes: 41 additions & 19 deletions app/controllers/mods_controller.rb
Original file line number Diff line number Diff line change
@@ -1,33 +1,22 @@
# 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?

# 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
@mods = find_mods(sanitize(params[:query])) if params[:query].present?
@filtered = false

# 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

if turbo_frame_request?
render partial: "mods", locals: { mods: @mods }
else
render :index
end
paginate_mods unless @filtered
render_index
end

def show
Expand All @@ -46,6 +35,39 @@ def show

private

def filter_by_author
return if params[:author].blank?

@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 if params[:query].blank?

@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)
Expand Down
28 changes: 18 additions & 10 deletions app/helpers/application_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
71 changes: 71 additions & 0 deletions app/helpers/pagination_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# 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.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
)
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
2 changes: 1 addition & 1 deletion app/javascript/controllers/mods_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export default class extends Controller {
clearTimeout(this.timeout)
this.timeout = setTimeout(() => {
event.target.form.requestSubmit();
}, 200)
}, 400)
}

submit(event) {
Expand Down
29 changes: 29 additions & 0 deletions app/javascript/controllers/theme_controller.js
Original file line number Diff line number Diff line change
@@ -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"
}
}
2 changes: 2 additions & 0 deletions app/models/concerns/displayable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ def details
end

def author_slug
return "unknown" unless author

author.parameterize
end

Expand Down
Loading
Loading