From 82fc1c6e17849566d52714061c271b083afa5f6d Mon Sep 17 00:00:00 2001 From: Sergei Poljanski Date: Thu, 16 Jul 2026 20:01:26 +0400 Subject: [PATCH] site: multi-page structure with shared nav; rework services, keys, contact --- asxpio.rb | 35 +++++++++++++------ public/copy.js | 21 +++++++++++ public/copyPGP.js | 41 ---------------------- public/copySSH.js | 41 ---------------------- public/sitemap.xml | 10 ++++++ test/app_test.rb | 8 ++--- views/contact.erb | 27 ++++++++++++++ views/index.erb | 60 +++++++++----------------------- views/keys.erb | 21 +++++++++++ views/partials/_contact_form.erb | 20 ++++++----- views/partials/_site_nav.erb | 8 +++++ views/thanks.erb | 2 +- 12 files changed, 143 insertions(+), 151 deletions(-) create mode 100644 public/copy.js delete mode 100644 public/copyPGP.js delete mode 100644 public/copySSH.js create mode 100644 views/contact.erb create mode 100644 views/keys.erb create mode 100644 views/partials/_site_nav.erb diff --git a/asxpio.rb b/asxpio.rb index e4835b4..335778f 100644 --- a/asxpio.rb +++ b/asxpio.rb @@ -89,12 +89,25 @@ class AsxpioWeb < Sinatra::Base end get '/' do - @form_errors = nil - @form_values = {} erb :index end + get '/keys' do + @page_title = 'Keys · Sergei Poljanski' + @page_desc = 'PGP fingerprint and SSH public key of Sergei Poljanski. Verify or download the key files.' + erb :keys + end + + get '/contact' do + @page_title = 'Contact · Sergei Poljanski' + @page_desc = 'Contact Sergei Poljanski: contact form, email, Telegram, phone. Legal entity details for invoices and contracts.' + @form_errors = nil + @form_values = {} + erb :contact + end + post '/contact' do + @page_title = 'Contact · Sergei Poljanski' name = params[:name].to_s.strip email = params[:email].to_s.strip subject = params[:subject].to_s.strip @@ -114,13 +127,13 @@ class AsxpioWeb < Sinatra::Base if @form_errors.any? status 422 - return erb :index + return erb :contact end unless RATE_LIMIT.allow?(client_ip) @form_errors[:base] = 'Too many submissions. Try again later or email ie@asxp.io directly.' status 429 - return erb :index + return erb :contact end begin @@ -131,7 +144,7 @@ class AsxpioWeb < Sinatra::Base $logger.error("notify_owner failed: #{e.class}: #{e.message}") @form_errors[:base] = 'Could not send message right now. Please email ie@asxp.io directly.' status 500 - return erb :index + return erb :contact end begin @@ -156,7 +169,7 @@ class AsxpioWeb < Sinatra::Base end get '/thanks' do - @page_title = 'Thanks — IE Sergei Poljanski' + @page_title = 'Thanks · IE Sergei Poljanski' @page_desc = 'Your message has been received. A confirmation copy has been sent to your inbox.' @noindex = true erb :thanks @@ -177,7 +190,7 @@ class AsxpioWeb < Sinatra::Base end get '/admin/invoices' do - @page_title = 'Invoices — admin' + @page_title = 'Invoices · admin' @noindex = true @invoices = Invoice.order(Sequel.desc(:created_at)).all erb :'admin/invoices/index' @@ -225,7 +238,7 @@ class AsxpioWeb < Sinatra::Base end get '/admin/invoices/new' do - @page_title = 'New invoice — admin' + @page_title = 'New invoice · admin' @noindex = true @form_errors = nil @form_values = {} @@ -240,7 +253,7 @@ class AsxpioWeb < Sinatra::Base get '/admin/invoices/:uuid/duplicate' do src = Invoice[uuid: params[:uuid]] or halt 404 term = (src.due_on - src.issued_on).to_i - @page_title = 'New invoice — admin' + @page_title = 'New invoice · admin' @noindex = true @form_errors = nil @form_values = { @@ -278,7 +291,7 @@ class AsxpioWeb < Sinatra::Base @form_errors = validate_invoice_params(@form_values) if @form_errors.any? - @page_title = 'New invoice — admin' + @page_title = 'New invoice · admin' @noindex = true status 422 return erb :'admin/invoices/new' @@ -309,7 +322,7 @@ class AsxpioWeb < Sinatra::Base get '/admin/invoices/:uuid' do @invoice = Invoice[uuid: params[:uuid]] or halt 404 - @page_title = "#{@invoice.number} — admin" + @page_title = "#{@invoice.number} · admin" @noindex = true erb :'admin/invoices/show' end diff --git a/public/copy.js b/public/copy.js new file mode 100644 index 0000000..5b16ed7 --- /dev/null +++ b/public/copy.js @@ -0,0 +1,21 @@ +// One copy button per [data-copy] element. Copies the data-copy value when +// set, otherwise the element's original text. Clipboard API needs a secure +// context — https in prod, localhost in dev; both hold. +document.querySelectorAll('[data-copy]').forEach(function (el) { + var text = el.getAttribute('data-copy') || el.textContent.trim(); + + var btn = document.createElement('span'); + btn.innerText = 'Copy'; + btn.classList.add('copy-button'); + el.appendChild(btn); + + btn.addEventListener('click', function () { + navigator.clipboard.writeText(text).then(function () { + btn.innerText = 'Copied!'; + setTimeout(function () { btn.innerText = 'Copy'; }, 2000); + }, function () { + btn.innerText = 'Failed'; + setTimeout(function () { btn.innerText = 'Copy'; }, 2000); + }); + }); +}); diff --git a/public/copyPGP.js b/public/copyPGP.js deleted file mode 100644 index ff02292..0000000 --- a/public/copyPGP.js +++ /dev/null @@ -1,41 +0,0 @@ -// Get the
 element
-const PGP = document.getElementById("myPGP");
-
-//Create a copy button element
-const copyButton = document.createElement("span");
-copyButton.innerText = "Copy";
-copyButton.classList.add("copy-button");
-
-// Append the copy button to the 
 tag
-PGP.appendChild(copyButton);
-
-// Add click event listener to the copy button
-copyButton.addEventListener("click", () => {
-  // Hide the copy button temporarily
-  copyButton.style.display = "none";
-
-  // Create a range and select the text inside the 
 tag
-  const range = document.createRange();
-  range.selectNode(PGP);
-  window.getSelection().removeAllRanges();
-  window.getSelection().addRange(range);
-
-  try {
-    // Copy the selected text to the clipboard
-    document.execCommand("copy");
-
-    // Alert the user that the text has been copied
-    copyButton.innerText = "Copied!";
-    setTimeout(function(){
-      copyButton.innerText = "Copy";
-    }, 2000);
-  } catch (err) {
-    console.error("Unable to copy text:", err);
-  } finally {
-    // Show the copy button again
-    copyButton.style.display = "inline";
-
-    // Deselect the text
-    window.getSelection().removeAllRanges();
-  }
-});
diff --git a/public/copySSH.js b/public/copySSH.js
deleted file mode 100644
index eb0d8eb..0000000
--- a/public/copySSH.js
+++ /dev/null
@@ -1,41 +0,0 @@
-// Get the 
 element
-const SSH = document.getElementById("mySSH");
-
-//Create a copy button element
-const copyButtonSSH = document.createElement("span");
-copyButtonSSH.innerText = "Copy";
-copyButtonSSH.classList.add("copy-button");
-
-// Append the copy button to the 
 tag
-SSH.appendChild(copyButtonSSH);
-
-// Add click event listener to the copy button
-copyButtonSSH.addEventListener("click", () => {
-  // Hide the copy button temporarily
-  copyButtonSSH.style.display = "none";
-
-  // Create a range and select the text inside the 
 tag
-  const range = document.createRange();
-  range.selectNode(SSH);
-  window.getSelection().removeAllRanges();
-  window.getSelection().addRange(range);
-
-  try {
-    // Copy the selected text to the clipboard
-    document.execCommand("copy");
-
-    // Alert the user that the text has been copied
-    copyButtonSSH.innerText = "Copied!";
-    setTimeout(function(){
-      copyButtonSSH.innerText = "Copy";
-    }, 2000);
-  } catch (err) {
-    console.error("Unable to copy text:", err);
-  } finally {
-    // Show the copy button again
-    copyButtonSSH.style.display = "inline";
-
-    // Deselect the text
-    window.getSelection().removeAllRanges();
-  }
-});
diff --git a/public/sitemap.xml b/public/sitemap.xml
index 993ff75..4c68335 100644
--- a/public/sitemap.xml
+++ b/public/sitemap.xml
@@ -5,4 +5,14 @@
         monthly
         1.0
     
+    
+        https://asxp.io/contact
+        monthly
+        0.8
+    
+    
+        https://asxp.io/keys
+        yearly
+        0.5
+    
 
diff --git a/test/app_test.rb b/test/app_test.rb
index 31fc301..14c3726 100644
--- a/test/app_test.rb
+++ b/test/app_test.rb
@@ -59,7 +59,7 @@ class AppTest < Minitest::Test
   end
 
   def test_contact_honeypot_pretends_success_and_sends_nothing
-    token = csrf_token_from('/')
+    token = csrf_token_from('/contact')
     post '/contact', contact_params(website: 'spam', authenticity_token: token)
     assert_equal 302, last_response.status
     assert_match %r{/thanks}, last_response.location
@@ -67,14 +67,14 @@ class AppTest < Minitest::Test
   end
 
   def test_contact_invalid_email_rejected
-    token = csrf_token_from('/')
+    token = csrf_token_from('/contact')
     post '/contact', contact_params(email: 'not-an-email', authenticity_token: token)
     assert_equal 422, last_response.status
     assert_empty Mail::TestMailer.deliveries
   end
 
   def test_contact_valid_submission_sends_two_mails
-    token = csrf_token_from('/')
+    token = csrf_token_from('/contact')
     post '/contact', contact_params(authenticity_token: token),
          'HTTP_X_FORWARDED_FOR' => fresh_ip
     assert_equal 302, last_response.status
@@ -86,7 +86,7 @@ class AppTest < Minitest::Test
 
   def test_contact_rate_limited_after_five
     ip = fresh_ip
-    token = csrf_token_from('/')
+    token = csrf_token_from('/contact')
     5.times do
       post '/contact', contact_params(authenticity_token: token), 'HTTP_X_FORWARDED_FOR' => ip
       assert_equal 302, last_response.status
diff --git a/views/contact.erb b/views/contact.erb
new file mode 100644
index 0000000..532ad17
--- /dev/null
+++ b/views/contact.erb
@@ -0,0 +1,27 @@
+<%== erb :'partials/_site_nav', layout: false %>
+
+
+

Contact

+ + <%== erb :'partials/_contact_form', layout: false %> + +
+
Email
ie@asxp.io
+
Telegram
t.me/ie_asxpi
+
Phone
+995 595 026 471
+
+
+ + diff --git a/views/index.erb b/views/index.erb index f97fe4f..5cbb058 100644 --- a/views/index.erb +++ b/views/index.erb @@ -1,54 +1,26 @@ +<%== erb :'partials/_site_nav', layout: false %> +
IE Sergei Poljanski

Sergei Poljanski

-

DevOps & infrastructure engineering — Tbilisi, Georgia.

+

DevOps & infrastructure engineering.

+

+ I build and operate infrastructure for clients worldwide. + Get in touch, or verify me via keys. +

+

Services

    -
  • IT infrastructure management
  • -
  • System administration
  • -
  • DevOps engineering
  • -
  • Software deployment & configuration
  • -
  • CI/CD pipeline development
  • -
  • Container orchestration (Docker, Kubernetes)
  • -
  • Infrastructure as Code (Terraform, Ansible)
  • -
  • Monitoring & observability (Prometheus, Grafana, Loki)
  • +
  • IT infrastructure management Hetzner Cloud · bare metal
  • +
  • System administration Linux · NixOS · Debian
  • +
  • Software development Lua · Ruby
  • +
  • Networking Traefik · DNS · WireGuard
  • +
  • CI/CD pipeline development GitHub Actions · Forgejo Actions
  • +
  • Container orchestration Docker · Kubernetes
  • +
  • Infrastructure as Code Terraform · Ansible
  • +
  • Monitoring & observability Prometheus · Grafana · Loki
- -
-

Keys

-
gpg --recv-keys 85F7AFEDAB7D97BE667F99F24F8851660FA4121B
-
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMZ+/5F7SxdTVW47iiStLpGK77oWfR5NgaK4tTSR/aVB ie+2026@asxp.io
-
- -
-

Contact

- - <%== erb :'partials/_contact_form', layout: false %> - -
-
Email
ie@asxp.io
-
Telegram
t.me/ie_asxpi
-
Phone
+995 595 026 471
-
-
- - - - - diff --git a/views/keys.erb b/views/keys.erb new file mode 100644 index 0000000..b2a16f6 --- /dev/null +++ b/views/keys.erb @@ -0,0 +1,21 @@ +<%== erb :'partials/_site_nav', layout: false %> + +
+
+
+ PGP + pgp.pub ↓ +
+
85F7 AFED AB7D 97BE 667F  99F2 4F88 5166 0FA4 121B
+
+ +
+
+ SSH + id_ed25519.pub ↓ +
+
ssh-ed25519 AAAA…R/aVB ie+2026@asxp.io
+
+
+ + diff --git a/views/partials/_contact_form.erb b/views/partials/_contact_form.erb index 162bd47..188bfb5 100644 --- a/views/partials/_contact_form.erb +++ b/views/partials/_contact_form.erb @@ -12,16 +12,18 @@
<%= errors[:base] %>
<% end %> -
- - - <% if errors[:name] %><%= errors[:name] %><% end %> -
+
+
+ + + <% if errors[:name] %><%= errors[:name] %><% end %> +
-
- - - <% if errors[:email] %><%= errors[:email] %><% end %> +
+ + + <% if errors[:email] %><%= errors[:email] %><% end %> +
diff --git a/views/partials/_site_nav.erb b/views/partials/_site_nav.erb new file mode 100644 index 0000000..8d9dcec --- /dev/null +++ b/views/partials/_site_nav.erb @@ -0,0 +1,8 @@ + diff --git a/views/thanks.erb b/views/thanks.erb index f1ddeaf..cf74e16 100644 --- a/views/thanks.erb +++ b/views/thanks.erb @@ -1,5 +1,5 @@
-

Thanks — your message is on its way.

+

Thanks! Your message is on its way.

A confirmation copy has been sent to your inbox.