admin: serve the UI from the root
The one human-facing screen owned the whole site but sat two segments
deep; GET / now renders it alongside the PROPFIND a DAV client asks
the same path, with detail pages at /contacts/:id.
Assisted-by: GLM-5.3 via pi
diff --git a/lib/pro_tacts/admin/contacts_index.rb b/lib/pro_tacts/admin/contacts_index.rb
index d5d28b8..edb2556 100644
--- a/lib/pro_tacts/admin/contacts_index.rb
+++ b/lib/pro_tacts/admin/contacts_index.rb
@@ -6,9 +6,9 @@ require "pro_tacts/admin/layout"
module ProTacts
module Admin
- # GET /admin/contacts — search first, no browsing (docs/DESIGN.md):
- # a query narrows to matches across every contact, an empty query
- # shows the ten most recently updated. Read-only: no add, no edit,
+ # GET / — search first, no browsing (docs/DESIGN.md): a query
+ # narrows to matches across every contact, an empty query shows
+ # the ten most recently updated. Read-only: no add, no edit,
# nothing to commit, so there is no state here beyond the query.
class ContactsIndex < Phlex::HTML
RECENT_LIMIT = 10
@@ -26,7 +26,7 @@ module ProTacts
def view_template
render Layout.new(title: "Contacts") do
h1(class: "type-h1") { "Contacts" }
- form(action: "/admin/contacts", method: "get") do
+ form(action: "/", method: "get") do
input(type: "search", name: "q", value: @query, placeholder: "Search contacts", autofocus: @query.empty?)
end
span(class: "type-label") { @query.empty? ? "recently updated" : "results" }
@@ -53,7 +53,7 @@ module ProTacts
#: (Row row) -> void
def render_row(row)
li do
- a(href: "/admin/contacts/#{row.contact.id}") do
+ a(href: "/contacts/#{row.contact.id}") do
span(class: "avatar") { Format.initials(row.fields.name || row.contact.id) }
div(style: "flex: 1; min-width: 0;") do
div(style: "font-weight: 550;") { row.fields.name || row.contact.id }
diff --git a/lib/pro_tacts/admin/contacts_show.rb b/lib/pro_tacts/admin/contacts_show.rb
index 4e59e94..deffaa7 100644
--- a/lib/pro_tacts/admin/contacts_show.rb
+++ b/lib/pro_tacts/admin/contacts_show.rb
@@ -6,7 +6,7 @@ require "pro_tacts/admin/layout"
module ProTacts
module Admin
- # GET /admin/contacts/:id — a contact's card, read-only. Follows
+ # GET /contacts/:id — a contact's card, read-only. Follows
# docs/DESIGN.md's alignment rule even before there is anything to
# edit: every row's type sits in one column and its value in the
# other, and an attribute with no data gets no row at all.
@@ -25,7 +25,7 @@ module ProTacts
def view_template
render Layout.new(title: @fields.name || @contact.id) do
- a(href: "/admin/contacts", class: "type-label") { "‹ contacts" }
+ a(href: "/", class: "type-label") { "‹ contacts" }
div(class: "card") do
div(class: "card-body") do
div(style: "display: flex; align-items: center; gap: var(--gl-space-s);") do
diff --git a/lib/pro_tacts/admin/layout.rb b/lib/pro_tacts/admin/layout.rb
index 42543a5..705bf88 100644
--- a/lib/pro_tacts/admin/layout.rb
+++ b/lib/pro_tacts/admin/layout.rb
@@ -6,7 +6,7 @@ module ProTacts
# vendored Gloss stylesheets (see public/vendor/gloss and
# docs/DESIGN.md), and a one-line header naming the app in type —
# "no brand mark" is one of the rules that document inherits from
- # Gloss. No JavaScript: nothing served under /admin yet needs any.
+ # Gloss. No JavaScript: nothing served here yet needs any.
class Layout < Phlex::HTML
#: (title: String) -> void
def initialize(title:)
@@ -28,7 +28,7 @@ module ProTacts
link(rel: "stylesheet", href: "/admin.css")
end
body do
- header(class: "admin-header") { a(href: "/admin/contacts") { "pro-tacts" } }
+ header(class: "admin-header") { a(href: "/") { "pro-tacts" } }
main(class: "admin-main") { yield }
end
end
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index 3763e97..fdf9ed5 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -82,33 +82,35 @@ module ProTacts
route do |r|
r.public
- # The read-only admin UI (docs/DESIGN.md). Under the same auth
- # gate as the CardDAV routes above it — "a few family members, all
+ # The card browser, one segment down from the page at the root:
+ # /contacts/:id names what the id is without spending the whole
+ # single-segment namespace on contact ids. Under the same auth
+ # gate as the CardDAV routes — "a few family members, all
# trusted" is the whole access model this app has, see README's
# simplifying assumptions.
- r.on "admin" do
- r.on "contacts" do
- r.is do
- r.get do
- response["Content-Type"] = "text/html; charset=utf-8"
- Admin::ContactsIndex.call(recent: store.contacts_by_recency, query: r.params["q"])
- end
- end
-
- r.get String do |id|
- contact = store.contact(id)
-
- # No match falls through to the empty-body 404 the
- # not_found handler fills in, same as the CardDAV GET.
- if contact
- response["Content-Type"] = "text/html; charset=utf-8"
- Admin::ContactsShow.call(contact:)
- end
+ r.on "contacts" do
+ r.get String do |id|
+ contact = store.contact(id)
+
+ # No match falls through to the empty-body 404 the
+ # not_found handler fills in, same as the CardDAV GET.
+ if contact
+ response["Content-Type"] = "text/html; charset=utf-8"
+ Admin::ContactsShow.call(contact:)
end
end
end
r.is "" do
+ # The contacts page itself: the root is the one human-facing
+ # screen's home (docs/DESIGN.md), a GET alongside the PROPFIND
+ # below it — same path, disjoint verbs, and a browser's plain
+ # GET is no DAV client's bootstrap.
+ r.get do
+ response["Content-Type"] = "text/html; charset=utf-8"
+ Admin::ContactsIndex.call(recent: store.contacts_by_recency, query: r.params["q"])
+ end
+
# DAV:current-user-principal (RFC 5397 section 3) — what a client
# asks the root for to find the principal it is acting as.
r.propfind do
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index cffad56..d0e2443 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -42,7 +42,7 @@ class AdminContactsPagesTest < Minitest::Test
def test_index_lists_recently_updated_contacts
with_contacts({"ada" => ADA}) do
- get "/admin/contacts"
+ get "/"
assert_equal 200, last_response.status
assert_equal "text/html; charset=utf-8", last_response["Content-Type"]
@@ -52,7 +52,7 @@ class AdminContactsPagesTest < Minitest::Test
end
def test_index_with_no_contacts_says_so
- with_contacts({}) { get "/admin/contacts" }
+ with_contacts({}) { get "/" }
assert_includes last_response.body, "No contacts yet."
end
@@ -63,7 +63,7 @@ class AdminContactsPagesTest < Minitest::Test
.sub("UID:ada", "UID:grace")
with_contacts({"ada" => ADA, "grace" => grace}) do
- get "/admin/contacts", q: "hopper"
+ get "/", q: "hopper"
assert_includes last_response.body, "Grace Hopper"
refute_includes last_response.body, "Ada Lovelace"
@@ -75,17 +75,17 @@ class AdminContactsPagesTest < Minitest::Test
# any of its values, or the groups it belongs to.
def test_search_matches_a_phone_or_email_value
with_contacts({"ada" => ADA}) do
- get "/admin/contacts", q: "555-0100"
+ get "/", q: "555-0100"
assert_includes last_response.body, "Ada Lovelace"
- get "/admin/contacts", q: "ada@example.com"
+ get "/", q: "ada@example.com"
assert_includes last_response.body, "Ada Lovelace"
end
end
def test_search_with_no_matches_says_so
with_contacts({"ada" => ADA}) do
- get "/admin/contacts", q: "nobody"
+ get "/", q: "nobody"
assert_includes last_response.body, "No contacts match."
end
@@ -93,7 +93,7 @@ class AdminContactsPagesTest < Minitest::Test
def test_show_renders_the_contacts_fields
with_contacts({"ada" => ADA}) do
- get "/admin/contacts/ada"
+ get "/contacts/ada"
assert_equal 200, last_response.status
assert_includes last_response.body, "Ada Lovelace"
@@ -112,7 +112,7 @@ class AdminContactsPagesTest < Minitest::Test
bare = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Bare Contact\r\nUID:bare\r\nEND:VCARD\r\n"
with_contacts({"bare" => bare}) do
- get "/admin/contacts/bare"
+ get "/contacts/bare"
assert_equal 200, last_response.status
refute_includes last_response.body, "notes"
@@ -121,7 +121,7 @@ class AdminContactsPagesTest < Minitest::Test
end
def test_show_of_an_unknown_contact_is_404
- with_contacts({}) { get "/admin/contacts/nope" }
+ with_contacts({}) { get "/contacts/nope" }
assert_equal 404, last_response.status
end