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..6b33d91 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
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/.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: []
diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb
new file mode 100644
index 0000000..cb9e22c
--- /dev/null
+++ b/app/controllers/admin/base_controller.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+module Admin
+ # Base controller for admin routes.
+ # Uses session-based authentication with ADMIN_PASSWORD env var.
+ # Sessions expire after 30 minutes of inactivity.
+ class BaseController < ApplicationController
+ SESSION_TIMEOUT = 30.minutes
+
+ before_action :authenticate_admin!
+
+ private
+
+ def authenticate_admin!
+ admin_password = ENV.fetch("ADMIN_PASSWORD", nil)
+
+ if admin_password.blank?
+ render plain: "Admin access is not configured. Set the ADMIN_PASSWORD environment variable.",
+ status: :service_unavailable
+ return
+ end
+
+ if session[:admin_authenticated]
+ if session[:admin_last_active].present? &&
+ Time.current - Time.zone.parse(session[:admin_last_active].to_s) < SESSION_TIMEOUT
+ session[:admin_last_active] = Time.current.iso8601
+ return
+ end
+
+ reset_admin_session
+ redirect_to admin_login_path, alert: "Session expired. Please log in again."
+ return
+ end
+
+ redirect_to admin_login_path
+ end
+
+ def reset_admin_session
+ session.delete(:admin_authenticated)
+ session.delete(:admin_last_active)
+ end
+ end
+end
diff --git a/app/controllers/admin/info_controller.rb b/app/controllers/admin/info_controller.rb
new file mode 100644
index 0000000..021ca88
--- /dev/null
+++ b/app/controllers/admin/info_controller.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+module Admin
+ # Allows admins to edit the info page content stored in Firestore.
+ class InfoController < Admin::BaseController
+ def edit
+ content = SiteContent.find("info_page")
+ @sections = content&.sections || SiteContent.default_info_sections
+ end
+
+ def update
+ sections = build_sections_from_params
+ SiteContent.save!("info_page", sections)
+ redirect_to admin_info_edit_path, notice: "Info page updated successfully."
+ end
+
+ # Add a blank section to the form
+ def add_section
+ content = SiteContent.find("info_page")
+ sections = content&.sections || SiteContent.default_info_sections
+ sections << SiteContent::Section.new(title: "", description: "", link_text: "", link_url: "")
+ SiteContent.save!("info_page", sections)
+ redirect_to admin_info_edit_path, notice: "New section added."
+ end
+
+ # Remove a section by index
+ def remove_section
+ content = SiteContent.find("info_page")
+ sections = content&.sections || SiteContent.default_info_sections
+ index = params[:index].to_i
+ sections.delete_at(index) if index >= 0 && index < sections.size
+ SiteContent.save!("info_page", sections)
+ redirect_to admin_info_edit_path, notice: "Section removed."
+ end
+
+ private
+
+ def build_sections_from_params
+ return [] unless params[:sections].is_a?(ActionController::Parameters)
+
+ sections = params[:sections].values.map do |section_params|
+ SiteContent::Section.new(
+ title: section_params[:title].to_s.strip,
+ description: section_params[:description].to_s.strip,
+ link_text: section_params[:link_text].to_s.strip,
+ link_url: section_params[:link_url].to_s.strip
+ )
+ end
+ sections.reject { |s| s.title.blank? && s.description.blank? }
+ end
+ end
+end
diff --git a/app/controllers/admin/sessions_controller.rb b/app/controllers/admin/sessions_controller.rb
new file mode 100644
index 0000000..a671215
--- /dev/null
+++ b/app/controllers/admin/sessions_controller.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+module Admin
+ class SessionsController < ApplicationController
+ def new
+ # Login form
+ redirect_to admin_info_edit_path if session[:admin_authenticated]
+ end
+
+ def create
+ admin_password = ENV.fetch("ADMIN_PASSWORD", nil)
+
+ if admin_password.blank?
+ render plain: "Admin access is not configured.", status: :service_unavailable
+ return
+ end
+
+ if ActiveSupport::SecurityUtils.secure_compare(params[:password].to_s, admin_password)
+ session[:admin_authenticated] = true
+ session[:admin_last_active] = Time.current.iso8601
+ Rails.logger.info("[Admin] Successful login from #{request.remote_ip}")
+ redirect_to admin_info_edit_path, notice: "Logged in successfully."
+ else
+ Rails.logger.warn("[Admin] Failed login attempt from #{request.remote_ip}")
+ flash.now[:alert] = "Invalid password."
+ render :new, status: :unauthorized
+ end
+ end
+
+ def destroy
+ Rails.logger.info("[Admin] Logout from #{request.remote_ip}")
+ session.delete(:admin_authenticated)
+ session.delete(:admin_last_active)
+ redirect_to root_path, notice: "Logged out."
+ end
+ end
+end
diff --git a/app/controllers/info_controller.rb b/app/controllers/info_controller.rb
index 5c08938..d657241 100644
--- a/app/controllers/info_controller.rb
+++ b/app/controllers/info_controller.rb
@@ -2,5 +2,9 @@
# InfoController
class InfoController < ApplicationController
- def index; end
+ def index
+ content = SiteContent.find("info_page")
+ @sections = content&.sections || SiteContent.default_info_sections
+ @last_updated = content&.updated_at
+ end
end
diff --git a/app/controllers/mods_controller.rb b/app/controllers/mods_controller.rb
index 41858dd..e0a2bbc 100644
--- a/app/controllers/mods_controller.rb
+++ b/app/controllers/mods_controller.rb
@@ -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
@@ -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)
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
diff --git a/app/helpers/pagination_helper.rb b/app/helpers/pagination_helper.rb
new file mode 100644
index 0000000..cc6988a
--- /dev/null
+++ b/app/helpers/pagination_helper.rb
@@ -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
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) {
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/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
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
diff --git a/app/models/site_content.rb b/app/models/site_content.rb
new file mode 100644
index 0000000..d6150b0
--- /dev/null
+++ b/app/models/site_content.rb
@@ -0,0 +1,75 @@
+# frozen_string_literal: true
+
+# SiteContent stores editable page content in Firestore.
+# Each page is a document in the "site_content" collection.
+#
+# Example Firestore document for the info page:
+# site_content/info_page => { sections: [...], updated_at: ... }
+class SiteContent
+ include Firestorable
+
+ COLLECTION = "site_content"
+ CACHE_TTL = 2.minutes
+
+ Section = Struct.new(:title, :description, :link_text, :link_url, keyword_init: true)
+
+ attr_reader :page_id, :sections, :updated_at
+
+ def initialize(page_id:, sections: [], updated_at: nil)
+ @page_id = page_id
+ @sections = sections.map { |s| s.is_a?(Section) ? s : Section.new(**s.symbolize_keys) }
+ @updated_at = updated_at
+ end
+
+ # Fetch content for a page, with caching
+ def self.find(page_id)
+ Rails.cache.fetch("site_content/#{page_id}", expires_in: CACHE_TTL) do
+ fetch_from_firestore(page_id)
+ end
+ end
+
+ # Fetch directly from Firestore (bypasses cache)
+ def self.fetch_from_firestore(page_id)
+ doc = firestore.doc("#{COLLECTION}/#{page_id}").get
+ return nil unless doc.exists?
+
+ new(
+ page_id: page_id,
+ sections: (doc[:sections] || []).map { |s| s.transform_keys(&:to_sym) },
+ updated_at: doc[:updated_at]
+ )
+ end
+
+ # Save content to Firestore and bust cache
+ def self.save!(page_id, sections)
+ data = {
+ sections: sections.map(&:to_h),
+ updated_at: Time.current.utc
+ }
+
+ firestore.doc("#{COLLECTION}/#{page_id}").set(data)
+ Rails.cache.delete("site_content/#{page_id}")
+
+ new(page_id: page_id, sections: sections, updated_at: data[:updated_at])
+ end
+
+ # Default info page content (used as fallback and for seeding)
+ def self.default_info_sections
+ [
+ Section.new(
+ title: "The Icarus Modding Discord Server",
+ description: "This is the unofficial modding Discord server for Icarus. " \
+ "It's a great place to get help with modding, ask questions, and meet other modders.",
+ link_text: "Join our Discord",
+ link_url: "https://discord.gg/linkarus-icarus-modding-936621749733302292"
+ ),
+ Section.new(
+ title: "The Icarus Modding Upvote Page",
+ description: "This is a page where you can upvote or add new mods you'd like to see " \
+ "for Icarus. It's a great way to let us know what you want to see in the future.",
+ link_text: "Modding Upvote Page",
+ link_url: "https://feedback.projectdaedalus.app/"
+ )
+ ]
+ end
+end
diff --git a/app/models/tool.rb b/app/models/tool.rb
index 1db43fb..06c3344 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 if url.blank?
+
+ url.split("?").first.split("/").last
end
def name_slug
diff --git a/app/views/admin/info/edit.html.erb b/app/views/admin/info/edit.html.erb
new file mode 100644
index 0000000..3e1a88d
--- /dev/null
+++ b/app/views/admin/info/edit.html.erb
@@ -0,0 +1,74 @@
+<% content_for(:title) { "Admin — Edit Info Page" } %>
+
+Edit Info Page
+
Enter the admin password to continue.
+ +- This is the unofficial modding Discord server for Icarus. - It's a great place to get help with modding, ask questions, and meet other modders. -
- - Join our Discord - + <% @sections.each do |section| %> + <%= link_to section.link_url.presence || "#", target: "_blank", class: "info-card block rounded-xl p-6 text-center border-2 border-icarus-500 bg-slate-100 dark:bg-gradient-to-b dark:from-slate-800 dark:to-slate-900 no-underline" do %> +- Upvote or add new mods you'd like to see for Icarus. - It's a great way to let us know what you want to see in the future. -
- - Modding Upvote Page - - <% end %> + <% if @sections.empty? %> +No information available at this time.
+ <% end %> - + <% if @last_updated %> +Last updated: <%= @last_updated.strftime("%B %d, %Y at %H:%M UTC") %>
+ <% end %> diff --git a/app/views/layouts/_header.html.erb b/app/views/layouts/_header.html.erb index 369e3e1..3ae9325 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" %> -