diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
new file mode 100644
index 0000000..3f9a352
--- /dev/null
+++ b/app/controllers/projects_controller.rb
@@ -0,0 +1,45 @@
+class ProjectsController < ApplicationController
+ allow_unauthenticated_access only: %i[ index ]
+
+ def index
+ @projects = Project.by_year
+ end
+
+ def new
+ @project = Project.new
+ end
+
+ def create
+ @project = Project.new(project_params)
+ if @project.save
+ redirect_to projects_url, notice: "Project was successfully created."
+ else
+ render :new, status: :unprocessable_content
+ end
+ end
+
+ def edit
+ @project = Project.find(params[:id])
+ end
+
+ def update
+ @project = Project.find(params[:id])
+ if @project.update(project_params)
+ redirect_to projects_url, notice: "Project was successfully updated."
+ else
+ render :edit, status: :unprocessable_content
+ end
+ end
+
+ def destroy
+ @project = Project.find(params[:id])
+ @project.destroy
+ redirect_to projects_url, notice: "Project was successfully destroyed."
+ end
+
+ private
+
+ def project_params
+ params.expect(project: [ :name, :year, :github_url, :license, :language, :description ])
+ end
+end
diff --git a/app/models/project.rb b/app/models/project.rb
new file mode 100644
index 0000000..1d5f812
--- /dev/null
+++ b/app/models/project.rb
@@ -0,0 +1,5 @@
+class Project < ApplicationRecord
+ validates :name, presence: true
+
+ scope :by_year, -> { order(year: :desc) }
+end
diff --git a/app/views/projects/_form.html.erb b/app/views/projects/_form.html.erb
new file mode 100644
index 0000000..8d37876
--- /dev/null
+++ b/app/views/projects/_form.html.erb
@@ -0,0 +1,37 @@
+<%= form_with model: project, class: "space-y-6", url: project.persisted? ? project_url(project) : projects_url do |form| %>
+ <%= render "shared/form_errors", object: project %>
+
+
+ <%= form.label :name, class: "block text-sm font-medium text-gray-700 dark:text-gray-200" %>
+ <%= form.text_field :name, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white shadow-xs focus:border-indigo-500 focus:ring-indigo-500" %>
+
+
+
+ <%= form.label :year, class: "block text-sm font-medium text-gray-700 dark:text-gray-200" %>
+ <%= form.number_field :year, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white shadow-xs focus:border-indigo-500 focus:ring-indigo-500" %>
+
+
+
+ <%= form.label :github_url, class: "block text-sm font-medium text-gray-700 dark:text-gray-200" %>
+ <%= form.text_field :github_url, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white shadow-xs focus:border-indigo-500 focus:ring-indigo-500" %>
+
+
+
+ <%= form.label :license, class: "block text-sm font-medium text-gray-700 dark:text-gray-200" %>
+ <%= form.text_field :license, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white shadow-xs focus:border-indigo-500 focus:ring-indigo-500" %>
+
+
+
+ <%= form.label :language, class: "block text-sm font-medium text-gray-700 dark:text-gray-200" %>
+ <%= form.text_field :language, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white shadow-xs focus:border-indigo-500 focus:ring-indigo-500" %>
+
+
+
+ <%= form.label :description, class: "block text-sm font-medium text-gray-700 dark:text-gray-200" %>
+ <%= form.text_area :description, rows: 3, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white shadow-xs focus:border-indigo-500 focus:ring-indigo-500" %>
+
+
+
+ <%= form.submit class: "rounded-md bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" %>
+
+<% end %>
diff --git a/app/views/projects/_project.html.erb b/app/views/projects/_project.html.erb
new file mode 100644
index 0000000..270adc2
--- /dev/null
+++ b/app/views/projects/_project.html.erb
@@ -0,0 +1,41 @@
+
+
+
+ <%= link_to project.name, project.github_url, target: "_blank", rel: "noopener noreferrer",
+ class: "text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300" %>
+
+
+ <% if project.year.present? %>
+
<%= project.year %>
+ <% end %>
+ <% if project.language.present? %>
+
+ <%= project.language %>
+
+ <% end %>
+ <% if project.license.present? %>
+
+ <%= project.license %>
+
+ <% end %>
+ <% if project.github_url.present? %>
+ <%= link_to project.github_url, target: "_blank", rel: "noopener noreferrer",
+ class: "text-gray-400 hover:text-gray-600 dark:hover:text-gray-300" do %>
+
+ <% end %>
+ <% end %>
+
+
+ <% if project.description.present? %>
+
+ <%= project.description %>
+
+ <% end %>
+ <% if authenticated? %>
+
+ <%= link_to "Edit", edit_project_path(project), class: "text-sm text-gray-400 hover:text-gray-600 dark:hover:text-gray-300" %>
+
+ <% end %>
+
diff --git a/app/views/projects/edit.html.erb b/app/views/projects/edit.html.erb
new file mode 100644
index 0000000..aec7e84
--- /dev/null
+++ b/app/views/projects/edit.html.erb
@@ -0,0 +1,3 @@
+Editing Project
+<%= button_to "Delete", @project, method: :delete, data: { turbo_confirm: "Are you sure?" }, class: "rounded-md bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 mb-6" %>
+<%= render "form", project: @project %>
diff --git a/app/views/projects/index.html.erb b/app/views/projects/index.html.erb
new file mode 100644
index 0000000..f95ed3e
--- /dev/null
+++ b/app/views/projects/index.html.erb
@@ -0,0 +1,17 @@
+<% content_for :title do %>
+Projects | <%= Rails.application.config.site_name %>
+<% end %>
+
+
+
+
+ <% if @projects.any? %>
+
+ <%= render @projects %>
+
+ <% else %>
+ No projects yet.
+ <% end %>
+
diff --git a/app/views/projects/new.html.erb b/app/views/projects/new.html.erb
new file mode 100644
index 0000000..b44c5f1
--- /dev/null
+++ b/app/views/projects/new.html.erb
@@ -0,0 +1,2 @@
+New Project
+<%= render "form", project: @project %>
diff --git a/app/views/shared/_admin_navigation.html.erb b/app/views/shared/_admin_navigation.html.erb
index 4c6d21e..f928161 100644
--- a/app/views/shared/_admin_navigation.html.erb
+++ b/app/views/shared/_admin_navigation.html.erb
@@ -17,6 +17,10 @@
class: "text-white hover:text-gray-300 transition-colors" do %>
New Link
<% end %>
+ <%= link_to new_project_path,
+ class: "text-white hover:text-gray-300 transition-colors" do %>
+ New Project
+ <% end %>
<%= link_to feeds_path,
class: "text-white hover:text-gray-300 transition-colors" do %>
Feeds
diff --git a/app/views/shared/_navigation.html.erb b/app/views/shared/_navigation.html.erb
index e063ef5..ac42308 100644
--- a/app/views/shared/_navigation.html.erb
+++ b/app/views/shared/_navigation.html.erb
@@ -17,8 +17,8 @@
<%= link_to "Presentations", "/p/presentations",
class: "#{current_page?('/p/presentations') ? 'text-blue-600 dark:text-blue-400 font-medium' : 'text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white'} transition-colors" %>
- <%= link_to "Projects", "/p/projects",
- class: "#{current_page?('/p/projects') ? 'text-blue-600 dark:text-blue-400 font-medium' : 'text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white'} transition-colors" %>
+ <%= link_to "Projects", projects_path,
+ class: "#{current_page?(projects_path) ? 'text-blue-600 dark:text-blue-400 font-medium' : 'text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white'} transition-colors" %>
<%= link_to "Links", links_path,
class: "#{current_page?(links_path) ? 'text-blue-600 dark:text-blue-400 font-medium' : 'text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white'} transition-colors" %>
diff --git a/config/routes.rb b/config/routes.rb
index b3e173d..9ddce3a 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -34,7 +34,7 @@
# handle old pages from capotej.com
get "/about", to: redirect("/p/about")
- get "/projects", to: redirect("/p/projects")
+ resources :projects, only: %i[ index new create edit update destroy ]
get "/presentations", to: redirect("/p/presentations")
get "/render-image-links-directly-inside-adium", to: "blog#redirect"
get "/finagle-with-scala-bootstrapper", to: "blog#redirect"
diff --git a/db/migrate/20260430005908_create_projects.rb b/db/migrate/20260430005908_create_projects.rb
new file mode 100644
index 0000000..f93523d
--- /dev/null
+++ b/db/migrate/20260430005908_create_projects.rb
@@ -0,0 +1,14 @@
+class CreateProjects < ActiveRecord::Migration[8.1]
+ def change
+ create_table :projects do |t|
+ t.string :name, null: false
+ t.integer :year
+ t.string :github_url
+ t.string :license
+ t.string :language
+ t.text :description
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 705f352..7e13aa3 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,26 +10,26 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.0].define(version: 2025_08_30_213252) do
+ActiveRecord::Schema[8.1].define(version: 2026_04_30_005908) do
create_table "active_storage_attachments", force: :cascade do |t|
- t.string "name", null: false
- t.string "record_type", null: false
- t.bigint "record_id", null: false
t.bigint "blob_id", null: false
t.datetime "created_at", null: false
+ t.string "name", null: false
+ t.bigint "record_id", null: false
+ t.string "record_type", null: false
t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id"
t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true
end
create_table "active_storage_blobs", force: :cascade do |t|
- t.string "key", null: false
- t.string "filename", null: false
- t.string "content_type"
- t.text "metadata"
- t.string "service_name", null: false
t.bigint "byte_size", null: false
t.string "checksum"
+ t.string "content_type"
t.datetime "created_at", null: false
+ t.string "filename", null: false
+ t.string "key", null: false
+ t.text "metadata"
+ t.string "service_name", null: false
t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true
end
@@ -40,95 +40,106 @@
end
create_table "feed_posts", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.integer "feed_id"
t.string "guid"
+ t.boolean "promoted"
+ t.datetime "published_at"
t.string "summary"
- t.string "url"
t.string "title"
- t.integer "feed_id"
- t.datetime "created_at", null: false
t.datetime "updated_at", null: false
- t.datetime "published_at"
- t.boolean "promoted"
+ t.string "url"
t.index ["guid"], name: "index_feed_posts_on_guid", unique: true
end
create_table "feeds", force: :cascade do |t|
- t.string "name", null: false
- t.string "url", null: false
t.datetime "created_at", null: false
+ t.string "name", null: false
t.datetime "updated_at", null: false
+ t.string "url", null: false
t.index ["name", "url"], name: "index_feeds_on_name_and_url", unique: true
end
create_table "links", force: :cascade do |t|
- t.string "url"
- t.string "title"
- t.string "description"
t.datetime "created_at", null: false
+ t.string "description"
+ t.string "title"
t.datetime "updated_at", null: false
+ t.string "url"
t.index ["url"], name: "index_links_on_url", unique: true
end
create_table "pages", force: :cascade do |t|
- t.string "title"
- t.string "slug"
t.datetime "created_at", null: false
- t.datetime "updated_at", null: false
t.text "markdown_body"
+ t.string "slug"
+ t.string "title"
+ t.datetime "updated_at", null: false
t.index ["slug"], name: "index_pages_on_slug", unique: true
end
create_table "papers", force: :cascade do |t|
- t.string "title"
- t.text "description"
- t.string "url"
t.datetime "created_at", null: false
+ t.text "description"
+ t.string "title"
t.datetime "updated_at", null: false
+ t.string "url"
t.index ["url"], name: "index_papers_on_url", unique: true
end
create_table "posts", force: :cascade do |t|
- t.boolean "draft"
- t.string "title"
t.datetime "created_at", null: false
- t.datetime "updated_at", null: false
- t.string "slug"
- t.text "markdown_excerpt"
+ t.boolean "draft"
t.text "markdown_body"
+ t.text "markdown_excerpt"
t.string "redirect_from"
+ t.string "slug"
+ t.string "title"
+ t.datetime "updated_at", null: false
t.index ["draft"], name: "index_posts_on_draft"
t.index ["redirect_from"], name: "index_posts_on_redirect_from", unique: true
t.index ["slug"], name: "index_posts_on_slug", unique: true
end
+ create_table "projects", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.text "description"
+ t.string "github_url"
+ t.string "language"
+ t.string "license"
+ t.string "name", null: false
+ t.datetime "updated_at", null: false
+ t.integer "year"
+ end
+
create_table "sessions", force: :cascade do |t|
- t.integer "user_id", null: false
- t.string "ip_address"
- t.string "user_agent"
t.datetime "created_at", null: false
+ t.string "ip_address"
t.datetime "updated_at", null: false
+ t.string "user_agent"
+ t.integer "user_id", null: false
t.index ["user_id"], name: "index_sessions_on_user_id"
end
create_table "taggings", force: :cascade do |t|
+ t.datetime "created_at", null: false
t.integer "post_id"
t.integer "tag_id"
- t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["post_id", "tag_id"], name: "index_taggings_on_post_id_and_tag_id", unique: true
end
create_table "tags", force: :cascade do |t|
- t.string "name"
t.datetime "created_at", null: false
+ t.string "name"
t.datetime "updated_at", null: false
t.index ["name"], name: "index_tags_on_name", unique: true
end
create_table "users", force: :cascade do |t|
+ t.datetime "created_at", null: false
t.string "email_address", null: false
t.string "password_digest", null: false
- t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["email_address"], name: "index_users_on_email_address", unique: true
end
diff --git a/test/controllers/projects_controller_test.rb b/test/controllers/projects_controller_test.rb
new file mode 100644
index 0000000..c047ba1
--- /dev/null
+++ b/test/controllers/projects_controller_test.rb
@@ -0,0 +1,78 @@
+require "test_helper"
+
+class ProjectsControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ @user = users(:one)
+ @project = projects(:abbey)
+ end
+
+ test "should get index without authentication" do
+ get projects_url
+ assert_response :success
+ end
+
+ test "should not get new without authentication" do
+ get new_project_url
+ assert_response :redirect
+ end
+
+ test "should get new when authenticated" do
+ sign_in(@user)
+ get new_project_url
+ assert_response :success
+ end
+
+ test "should create project when authenticated" do
+ sign_in(@user)
+ assert_difference("Project.count", 1) do
+ post projects_url, params: {
+ project: {
+ name: "New Project",
+ year: 2025,
+ github_url: "https://github.com/capotej/new-project",
+ license: "MIT",
+ language: "Go",
+ description: "A new project."
+ }
+ }
+ end
+ assert_redirected_to projects_url
+ end
+
+ test "should not create project with invalid data" do
+ sign_in(@user)
+ assert_no_difference("Project.count") do
+ post projects_url, params: { project: { name: "" } }
+ end
+ assert_response :unprocessable_content
+ end
+
+ test "should get edit when authenticated" do
+ sign_in(@user)
+ get edit_project_url(@project)
+ assert_response :success
+ end
+
+ test "should update project when authenticated" do
+ sign_in(@user)
+ patch project_url(@project), params: {
+ project: { name: "Updated Abbey" }
+ }
+ assert_redirected_to projects_url
+ assert_equal "Updated Abbey", @project.reload.name
+ end
+
+ test "should destroy project when authenticated" do
+ sign_in(@user)
+ assert_difference("Project.count", -1) do
+ delete project_url(@project)
+ end
+ assert_redirected_to projects_url
+ end
+
+ private
+
+ def sign_in(user)
+ post session_url, params: { email_address: user.email_address, password: "password" }
+ end
+end
diff --git a/test/fixtures/projects.yml b/test/fixtures/projects.yml
new file mode 100644
index 0000000..61335b2
--- /dev/null
+++ b/test/fixtures/projects.yml
@@ -0,0 +1,15 @@
+abbey:
+ name: Abbey
+ year: 2024
+ github_url: https://github.com/capotej/abbey
+ license: MIT
+ language: Ruby
+ description: A personal blog built with Rails.
+
+example:
+ name: Example Project
+ year: 2022
+ github_url: https://github.com/capotej/example
+ license: Apache-2.0
+ language: Go
+ description: An example Go project.
diff --git a/test/models/project_test.rb b/test/models/project_test.rb
new file mode 100644
index 0000000..30b15b7
--- /dev/null
+++ b/test/models/project_test.rb
@@ -0,0 +1,27 @@
+require "test_helper"
+
+class ProjectTest < ActiveSupport::TestCase
+ test "valid project with all fields" do
+ project = Project.new(
+ name: "Abbey",
+ year: 2024,
+ github_url: "https://github.com/capotej/abbey",
+ license: "MIT",
+ language: "Ruby",
+ description: "A personal blog."
+ )
+ assert project.valid?
+ end
+
+ test "invalid without name" do
+ project = Project.new(name: nil)
+ assert_not project.valid?
+ assert_includes project.errors[:name], "can't be blank"
+ end
+
+ test "ordered by year descending by default" do
+ old = Project.create!(name: "Old", year: 2020)
+ recent = Project.create!(name: "Recent", year: 2024)
+ assert_equal [ recent, old ], Project.where(id: [ old.id, recent.id ]).order(year: :desc)
+ end
+end
diff --git a/test/system/projects_test.rb b/test/system/projects_test.rb
new file mode 100644
index 0000000..7750966
--- /dev/null
+++ b/test/system/projects_test.rb
@@ -0,0 +1,96 @@
+require "application_system_test_case"
+
+class ProjectsTest < ApplicationSystemTestCase
+ test "can view projects page publicly" do
+ visit projects_path
+ assert_selector "h1", text: "Projects"
+ assert_text "Abbey"
+ assert_text "A personal blog built with Rails."
+ end
+
+ test "projects page shows GitHub link" do
+ visit projects_path
+ assert_link "Abbey", href: "https://github.com/capotej/abbey"
+ end
+
+ test "projects are ordered by year descending" do
+ visit projects_path
+ # Abbey (2024) should appear before Example Project (2022)
+ first_position = page.text.index("Abbey")
+ second_position = page.text.index("Example Project")
+ assert first_position < second_position, "Abbey should appear before Example Project"
+ end
+
+ test "projects page shows language and license badges" do
+ visit projects_path
+ assert_text "Ruby"
+ assert_text "MIT"
+ end
+
+ test "can create a new project when logged in" do
+ sign_in_as users(:one)
+ visit new_project_path
+
+ fill_in "Name", with: "New Lib"
+ fill_in "Year", with: "2025"
+ fill_in "Github url", with: "https://github.com/capotej/new-lib"
+ fill_in "License", with: "ISC"
+ fill_in "Language", with: "Rust"
+ fill_in "Description", with: "A new Rust library."
+ click_button "Create Project"
+
+ assert_current_path projects_path
+ assert_text "New Lib"
+ assert_text "Rust"
+ assert_text "ISC"
+ end
+
+ test "cannot create project with missing name" do
+ sign_in_as users(:one)
+ visit new_project_path
+
+ click_button "Create Project"
+ assert_text "can't be blank"
+ end
+
+ test "edit link visible on projects when logged in" do
+ sign_in_as users(:one)
+ visit projects_path
+ assert_link "Edit"
+ end
+
+ test "edit link hidden on projects when logged out" do
+ visit projects_path
+ assert_no_link "Edit"
+ end
+
+ test "can edit an existing project" do
+ sign_in_as users(:one)
+ visit projects_path
+
+ click_link "Edit", match: :first
+ fill_in "Name", with: "Updated Abbey"
+ click_button "Update Project"
+
+ assert_current_path projects_path
+ assert_text "Updated Abbey"
+ end
+
+ test "can delete a project" do
+ sign_in_as users(:one)
+ visit projects_path
+
+ click_link "Edit", match: :first
+ accept_confirm do
+ click_button "Delete"
+ end
+
+ assert_current_path projects_path
+ assert_no_text "Abbey"
+ end
+
+ test "unauthenticated user redirected to login when accessing new project form" do
+ visit new_project_path
+ assert_current_path new_session_path
+ end
+end