Make home capture actions open the picker in place
The "Take a photo" / "Upload a file" buttons no longer navigate to the
capture page — the whole home page is now one captureApp() Alpine
component, so the buttons trigger the hidden camera/file inputs and
choosing a file swaps the recent-asset list for the save form.
Extract the shared icon helper into an Icons mixin and the save step
into a CaptureForm component, reused by both the capture and home views.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0147qRctyhELxJgGs3nHizEM
diff --git a/lib/views/capture.rb b/lib/views/capture.rb
index a0df4b9..c7f6e97 100644
--- a/lib/views/capture.rb
+++ b/lib/views/capture.rb
@@ -1,11 +1,13 @@
# frozen_string_literal: true
require_relative "layout"
+require_relative "icons"
+require_relative "capture_form"
module Domus
module Views
class Capture < Phlex::HTML
- ICONS_DIR = File.expand_path("../../public/icons", __dir__)
+ include Icons
def view_template
doctype
@@ -79,95 +81,10 @@ module Domus
p(class: "drop-hint") { plain "or drop a file onto this card" }
end
- form(
- "x-show": "state === 'saved'",
- method: "post",
- action: "/files",
- enctype: "multipart/form-data",
- "@submit": "onSubmit()"
- ) do
- input(
- type: "file",
- name: "file",
- accept: "image/*",
- capture: "environment",
- class: "sr-only",
- "x-ref": "cameraInput",
- "@change": "onCameraInput($event)"
- )
- input(
- type: "file",
- name: "file",
- accept: "image/*",
- class: "sr-only",
- "x-ref": "fileInput",
- "@change": "onFileInput($event)"
- )
- div(class: "preview-zone") do
- img(
- "x-show": "preview",
- ":src": "preview",
- alt: "Captured image"
- )
- div(
- class: "preview-placeholder",
- "x-show": "!preview"
- ) do
- icon("image")
- plain "captured file"
- end
- end
-
- div(class: "save-form") do
- div(class: "asset-inputs") do
- p(class: "asset-inputs-label") { plain "Assets" }
- template("x-for": "(_, i) in assetNames", ":key": "i") do
- div(class: "asset-input-row") do
- input(
- type: "text",
- name: "asset_names[]",
- placeholder: "Asset name",
- "x-model": "assetNames[i]",
- "@keydown.enter.prevent": "addAsset()"
- )
- button(
- type: "button",
- class: "btn-remove-asset",
- "@click": "removeAsset(i)"
- ) { icon("trash") }
- end
- end
- button(
- type: "button",
- class: "asset-add-btn",
- "@click": "addAsset()"
- ) { plain "Add another" }
- end
-
- div(class: "btn-row") do
- button(type: "submit", class: "btn btn-primary") do
- icon("check")
- plain "Save image"
- end
- button(
- type: "button",
- class: "btn",
- "@click": "reset()"
- ) { plain "Discard" }
- end
- end
- end
+ render CaptureForm.new
end
end
end
-
- ICONS = Hash.new do |cache, name|
- cache[name] = File.read(File.join(ICONS_DIR, "#{name}.svg")).freeze
- end
-
- def icon(name)
- raw safe(ICONS[name])
- end
end
end
end
diff --git a/lib/views/capture_form.rb b/lib/views/capture_form.rb
new file mode 100644
index 0000000..6cf14c3
--- /dev/null
+++ b/lib/views/capture_form.rb
@@ -0,0 +1,91 @@
+# frozen_string_literal: true
+
+require "phlex"
+require_relative "icons"
+
+module Domus
+ module Views
+ # The save step of a capture: the hidden camera/file inputs plus the
+ # preview and asset-naming form. Driven by the captureApp() Alpine data,
+ # and shared by both the capture page and the home page so the capture
+ # actions can open the picker in place.
+ class CaptureForm < Phlex::HTML
+ include Icons
+
+ def view_template
+ form(
+ "x-show": "state === 'saved'",
+ method: "post",
+ action: "/files",
+ enctype: "multipart/form-data",
+ "@submit": "onSubmit()"
+ ) do
+ input(
+ type: "file",
+ name: "file",
+ accept: "image/*",
+ capture: "environment",
+ class: "sr-only",
+ "x-ref": "cameraInput",
+ "@change": "onCameraInput($event)"
+ )
+ input(
+ type: "file",
+ name: "file",
+ accept: "image/*",
+ class: "sr-only",
+ "x-ref": "fileInput",
+ "@change": "onFileInput($event)"
+ )
+
+ div(class: "preview-zone") do
+ img("x-show": "preview", ":src": "preview", alt: "Captured image")
+ div(class: "preview-placeholder", "x-show": "!preview") do
+ icon("image")
+ plain "captured file"
+ end
+ end
+
+ div(class: "save-form") do
+ div(class: "asset-inputs") do
+ p(class: "asset-inputs-label") { plain "Assets" }
+ template("x-for": "(_, i) in assetNames", ":key": "i") do
+ div(class: "asset-input-row") do
+ input(
+ type: "text",
+ name: "asset_names[]",
+ placeholder: "Asset name",
+ "x-model": "assetNames[i]",
+ "@keydown.enter.prevent": "addAsset()"
+ )
+ button(
+ type: "button",
+ class: "btn-remove-asset",
+ "@click": "removeAsset(i)"
+ ) { icon("trash") }
+ end
+ end
+ button(
+ type: "button",
+ class: "asset-add-btn",
+ "@click": "addAsset()"
+ ) { plain "Add another" }
+ end
+
+ div(class: "btn-row") do
+ button(type: "submit", class: "btn btn-primary") do
+ icon("check")
+ plain "Save image"
+ end
+ button(
+ type: "button",
+ class: "btn",
+ "@click": "reset()"
+ ) { plain "Discard" }
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/views/home.rb b/lib/views/home.rb
index a61a719..968e9da 100644
--- a/lib/views/home.rb
+++ b/lib/views/home.rb
@@ -1,14 +1,17 @@
# frozen_string_literal: true
require "phlex"
+require_relative "icons"
+require_relative "capture_form"
module Domus
module Views
# The home page — the archive's front door. Capture actions live in the
- # header (and a thumb-reachable dock on small screens); the recent assets
- # run as a single-column list below. Calm Archive language throughout.
+ # header (and a thumb-reachable dock on small screens) and open the
+ # picker in place: the whole page is one captureApp() Alpine component,
+ # so choosing a file swaps the recent-asset list for the save form.
class Home < Phlex::HTML
- ICONS_DIR = File.expand_path("../../public/icons", __dir__)
+ include Icons
def initialize(assets:, total:)
@assets = assets
@@ -23,11 +26,14 @@ module Domus
meta(name: "viewport", content: "width=device-width, initial-scale=1")
title { "Domus" }
link(rel: "stylesheet", href: "/app.css")
+ script(defer: true, src: "/capture.js")
+ script(defer: true, src: "https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js")
end
body do
- div(class: "page") do
+ div(class: "page", "x-data": "captureApp()") do
render_header
render_main
+ render_capture
render_footer
render_dock
end
@@ -43,16 +49,25 @@ module Domus
span(class: "logo-mark")
plain "domus"
end
- render_actions(class: "actions")
+ render_actions(class: "actions", "x-show": "state === 'capture'")
end
end
- # The capture entry points. Both lead to the capture flow, where the
- # camera and file pickers live.
+ # The capture entry points. Rather than navigating, they trigger the
+ # hidden inputs that captureApp() drives, opening the camera or file
+ # picker in place.
def render_actions(**attrs)
div(**attrs) do
- a(href: "/capture", class: "browse") { plain "Upload a file" }
- a(href: "/capture", class: "add-btn") do
+ button(
+ type: "button",
+ class: "browse",
+ "@click": "$refs.fileInput.click()"
+ ) { plain "Upload a file" }
+ button(
+ type: "button",
+ class: "add-btn",
+ "@click": "$refs.cameraInput.click()"
+ ) do
icon("camera")
plain "Take a photo"
end
@@ -60,7 +75,7 @@ module Domus
end
def render_main
- main(class: "wrap") do
+ main(class: "wrap", "x-show": "state === 'capture'") do
section do
div(class: "sec-h") do
h3 { plain "Recent assets" }
@@ -95,8 +110,17 @@ module Domus
end
end
+ # The capture save step, shown once a photo or file has been chosen.
+ def render_capture
+ div(class: "content", "x-show": "state === 'saved'", "x-cloak": true) do
+ div(class: "card") do
+ render CaptureForm.new
+ end
+ end
+ end
+
def render_footer
- footer(class: "foot") do
+ footer(class: "foot", "x-show": "state === 'capture'") do
span(class: "fm") { plain "v1.0" }
end
end
@@ -104,7 +128,7 @@ module Domus
# On small screens the capture actions move to a thumb-reachable dock
# fixed to the bottom of the viewport; it's hidden on wider screens.
def render_dock
- render_actions(class: "dock")
+ render_actions(class: "dock", "x-show": "state === 'capture'")
end
# Compact, archival relative time — "now", "5m", "3h", "2d", "1w", "4mo".
@@ -131,14 +155,6 @@ module Domus
"#{days / 365}y"
end
-
- ICONS = Hash.new do |cache, name|
- cache[name] = File.read(File.join(ICONS_DIR, "#{name}.svg")).freeze
- end
-
- def icon(name)
- raw safe(ICONS[name])
- end
end
end
end
diff --git a/lib/views/icons.rb b/lib/views/icons.rb
new file mode 100644
index 0000000..7d86983
--- /dev/null
+++ b/lib/views/icons.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module Domus
+ module Views
+ # Inlines the SVG files from public/icons so they inherit currentColor
+ # and can be sized and recoloured with CSS. Mixed into the Phlex views
+ # that draw icons.
+ module Icons
+ ICONS_DIR = File.expand_path("../../public/icons", __dir__)
+
+ ICONS = Hash.new do |cache, name|
+ cache[name] = File.read(File.join(ICONS_DIR, "#{name}.svg")).freeze
+ end
+
+ def icon(name)
+ raw safe(ICONS[name])
+ end
+ end
+ end
+end
diff --git a/public/app.css b/public/app.css
index 7f54531..3b7db60 100644
--- a/public/app.css
+++ b/public/app.css
@@ -75,6 +75,9 @@ body {
clip: rect(0,0,0,0); white-space: nowrap; border: 0;
}
+/* Hide Alpine-managed nodes until the component initializes. */
+[x-cloak] { display: none !important; }
+
/* ---- page shell ---- */
.page {
min-height: 100dvh;
diff --git a/test/test_app.rb b/test/test_app.rb
index 3108256..9fa82b2 100644
--- a/test/test_app.rb
+++ b/test/test_app.rb
@@ -45,6 +45,17 @@ class TestApp < Minitest::Test
assert_includes last_response.body, "Nothing tracked yet."
end
+ def test_root_capture_actions_open_picker_in_place
+ get "/"
+ body = last_response.body
+ # The capture flow is embedded, so the actions trigger the file inputs
+ # rather than navigating away.
+ assert_includes body, "captureApp()"
+ assert_includes body, "$refs.cameraInput.click()"
+ assert_includes body, "$refs.fileInput.click()"
+ refute_includes body, 'href="/capture"'
+ end
+
def test_capture_page
get "/capture"
assert_equal 200, last_response.status