diff --git a/app/controllers/cards/pins_controller.rb b/app/controllers/cards/pins_controller.rb
index f8da07da31..d46c6a48df 100644
--- a/app/controllers/cards/pins_controller.rb
+++ b/app/controllers/cards/pins_controller.rb
@@ -9,14 +9,22 @@ def create
@pin = @card.pin_by Current.user
broadcast_add_pin_to_tray
- render_pin_button_replacement
+
+ respond_to do |format|
+ format.turbo_stream { render_pin_button_replacement }
+ format.json { head :no_content }
+ end
end
def destroy
@pin = @card.unpin_by Current.user
broadcast_remove_pin_from_tray
- render_pin_button_replacement
+
+ respond_to do |format|
+ format.turbo_stream { render_pin_button_replacement }
+ format.json { head :no_content }
+ end
end
private
diff --git a/app/controllers/my/pins_controller.rb b/app/controllers/my/pins_controller.rb
index 4468c75d5c..18b58910f2 100644
--- a/app/controllers/my/pins_controller.rb
+++ b/app/controllers/my/pins_controller.rb
@@ -1,6 +1,15 @@
class My::PinsController < ApplicationController
def index
- @pins = Current.user.pins.includes(:card).ordered.limit(20)
+ @pins = user_pins
fresh_when etag: [ @pins, @pins.collect(&:card) ]
end
+
+ private
+ def user_pins
+ Current.user.pins.includes(:card).ordered.limit(pins_limit)
+ end
+
+ def pins_limit
+ request.format.json? ? 100 : 20
+ end
end
diff --git a/app/views/my/pins/index.json.jbuilder b/app/views/my/pins/index.json.jbuilder
new file mode 100644
index 0000000000..f3a39ae845
--- /dev/null
+++ b/app/views/my/pins/index.json.jbuilder
@@ -0,0 +1,3 @@
+json.array! @pins do |pin|
+ json.partial! "cards/card", card: pin.card
+end
diff --git a/docs/API.md b/docs/API.md
index f7bdacb306..3a68c46dde 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -805,6 +805,79 @@ __Response:__
Returns `204 No Content` on success.
+## Pins
+
+Pins let users keep quick access to important cards.
+
+### `POST /:account_slug/cards/:card_number/pin`
+
+Pins a card for the current user.
+
+__Response:__
+
+Returns `204 No Content` on success.
+
+### `DELETE /:account_slug/cards/:card_number/pin`
+
+Unpins a card for the current user.
+
+__Response:__
+
+Returns `204 No Content` on success.
+
+### `GET /my/pins`
+
+Returns the current user's pinned cards. This endpoint is not paginated and returns up to 100 cards.
+
+__Response:__
+
+```json
+[
+ {
+ "id": "03f5vaeq985jlvwv3arl4srq2",
+ "number": 1,
+ "title": "First!",
+ "status": "published",
+ "description": "Hello, World!",
+ "description_html": "
",
+ "image_url": null,
+ "tags": ["programming"],
+ "golden": false,
+ "last_active_at": "2025-12-05T19:38:48.553Z",
+ "created_at": "2025-12-05T19:38:48.540Z",
+ "url": "http://fizzy.localhost:3006/897362094/cards/4",
+ "board": {
+ "id": "03f5v9zkft4hj9qq0lsn9ohcm",
+ "name": "Fizzy",
+ "all_access": true,
+ "created_at": "2025-12-05T19:36:35.534Z",
+ "url": "http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm",
+ "creator": {
+ "id": "03f5v9zjw7pz8717a4no1h8a7",
+ "name": "David Heinemeier Hansson",
+ "role": "owner",
+ "active": true,
+ "email_address": "david@example.com",
+ "created_at": "2025-12-05T19:36:35.401Z",
+ "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7",
+ "avatar_url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7/avatar"
+ }
+ },
+ "creator": {
+ "id": "03f5v9zjw7pz8717a4no1h8a7",
+ "name": "David Heinemeier Hansson",
+ "role": "owner",
+ "active": true,
+ "email_address": "david@example.com",
+ "created_at": "2025-12-05T19:36:35.401Z",
+ "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7",
+ "avatar_url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7/avatar"
+ },
+ "comments_url": "http://fizzy.localhost:3006/897362094/cards/4/comments"
+ }
+]
+```
+
## Comments
Comments are attached to cards and support rich text.
diff --git a/test/controllers/cards/pins_controller_test.rb b/test/controllers/cards/pins_controller_test.rb
index 3bc5698277..db26a12e71 100644
--- a/test/controllers/cards/pins_controller_test.rb
+++ b/test/controllers/cards/pins_controller_test.rb
@@ -17,6 +17,17 @@ class Cards::PinsControllerTest < ActionDispatch::IntegrationTest
assert_response :success
end
+ test "create as JSON" do
+ card = cards(:layout)
+
+ assert_not card.pinned_by?(users(:kevin))
+
+ post card_pin_path(card), as: :json
+
+ assert_response :no_content
+ assert card.reload.pinned_by?(users(:kevin))
+ end
+
test "destroy" do
assert_changes -> { cards(:shipping).pinned_by?(users(:kevin)) }, from: true, to: false do
perform_enqueued_jobs do
@@ -28,4 +39,15 @@ class Cards::PinsControllerTest < ActionDispatch::IntegrationTest
assert_response :success
end
+
+ test "destroy as JSON" do
+ card = cards(:shipping)
+
+ assert card.pinned_by?(users(:kevin))
+
+ delete card_pin_path(card), as: :json
+
+ assert_response :no_content
+ assert_not card.reload.pinned_by?(users(:kevin))
+ end
end
diff --git a/test/controllers/my/pins_controller_test.rb b/test/controllers/my/pins_controller_test.rb
index 3c8ca33e22..daff963549 100644
--- a/test/controllers/my/pins_controller_test.rb
+++ b/test/controllers/my/pins_controller_test.rb
@@ -11,4 +11,14 @@ class My::PinsControllerTest < ActionDispatch::IntegrationTest
assert_response :success
assert_select "div", text: /#{users(:kevin).pins.first.card.title}/
end
+
+ test "index as JSON" do
+ expected_ids = users(:kevin).pins.ordered.pluck(:card_id)
+
+ get my_pins_path(format: :json)
+
+ assert_response :success
+ assert_equal expected_ids.count, @response.parsed_body.count
+ assert_equal expected_ids, @response.parsed_body.map { |card| card["id"] }
+ end
end