fuik 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +162 -25
  3. data/app/assets/images/fuik/icons/46elks.jpg +0 -0
  4. data/app/assets/images/fuik/icons/lettermint.jpg +0 -0
  5. data/app/assets/images/fuik/icons/smtp2go.jpg +0 -0
  6. data/app/assets/javascript/attractivejs-0.12.0.min.js +1 -0
  7. data/app/assets/stylesheets/fuik/application.css +204 -81
  8. data/app/controllers/concerns/fuik/event_type.rb +32 -7
  9. data/app/controllers/fuik/events_controller.rb +7 -2
  10. data/app/controllers/fuik/retries_controller.rb +12 -0
  11. data/app/controllers/fuik/webhooks_controller.rb +29 -8
  12. data/app/jobs/fuik/webhook_processing_job.rb +21 -0
  13. data/app/models/fuik/event.rb +36 -0
  14. data/app/models/fuik/webhook_event/filterable.rb +25 -0
  15. data/app/models/fuik/webhook_event.rb +34 -1
  16. data/app/views/fuik/events/_filters.html.erb +29 -0
  17. data/app/views/fuik/events/index.html.erb +7 -1
  18. data/app/views/fuik/events/show.html.erb +43 -20
  19. data/app/views/layouts/fuik/application.html.erb +12 -2
  20. data/config/routes.rb +4 -1
  21. data/lib/fuik/configuration.rb +29 -0
  22. data/lib/fuik/engine.rb +0 -4
  23. data/lib/fuik/errors.rb +5 -0
  24. data/lib/fuik/notifications.rb +48 -0
  25. data/lib/fuik/routing/provider_constraint.rb +4 -4
  26. data/lib/fuik/test_helpers.rb +33 -0
  27. data/lib/fuik/version.rb +1 -1
  28. data/lib/fuik.rb +9 -0
  29. data/lib/generators/fuik/provider/templates/base.rb.tt +11 -2
  30. data/lib/generators/fuik/provider/templates/github/base.rb.tt +3 -0
  31. data/lib/generators/fuik/provider/templates/lettermint/base.rb.tt +32 -0
  32. data/lib/generators/fuik/provider/templates/lettermint/message_failed.rb.tt +16 -0
  33. data/lib/generators/fuik/provider/templates/lettermint/message_hard_bounced.rb.tt +16 -0
  34. data/lib/generators/fuik/provider/templates/lettermint/message_soft_bounced.rb.tt +16 -0
  35. data/lib/generators/fuik/provider/templates/lettermint/message_suppressed.rb.tt +15 -0
  36. data/lib/generators/fuik/provider/templates/loops/base.rb.tt +2 -0
  37. data/lib/generators/fuik/provider/templates/postmark/base.rb.tt +1 -0
  38. data/lib/generators/fuik/provider/templates/smtp2go/base.rb.tt +4 -0
  39. data/lib/generators/fuik/provider/templates/smtp2go/bounce.rb.tt +16 -0
  40. data/lib/generators/fuik/provider/templates/smtp2go/reject.rb.tt +14 -0
  41. data/lib/generators/fuik/provider/templates/smtp2go/spam.rb.tt +13 -0
  42. data/lib/generators/fuik/provider/templates/smtp2go/unsubscribe.rb.tt +13 -0
  43. data/lib/generators/fuik/provider/templates/spinal/base.rb.tt +16 -0
  44. data/lib/generators/fuik/provider/templates/spinal/collection_synced.rb.tt +14 -0
  45. data/lib/generators/fuik/provider/templates/spinal/resource_created.rb.tt +14 -0
  46. data/lib/generators/fuik/provider/templates/spinal/resource_pushed.rb.tt +13 -0
  47. data/lib/generators/fuik/provider/templates/spinal/resource_scheduled.rb.tt +13 -0
  48. data/lib/generators/fuik/provider/templates/spinal/resource_unscheduled.rb.tt +12 -0
  49. metadata +32 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b15633f53ffe1c5c7dad94fb1d5151a8c2bce010318854cc50a4e3afbe48c828
4
- data.tar.gz: 0ba925e04a5271e77a8a8ed4c80bbda7a4a00a5d9d87c6b3b304104519988a29
3
+ metadata.gz: 7e63ae360a189d38134db206274a5733ad73ea37ae2b338686c952df7ac3c109
4
+ data.tar.gz: c59410136f9c4143690b96b496c3e83fdf0419d806cc308a5ab4e73c1c1b21fb
5
5
  SHA512:
6
- metadata.gz: 5c7c0c9ee432bd0702969c6238c0ceea4200da1370350a4d88337f14252bc6f4bce0990a0c80e49dd862de9f25459c4cd1a1f5b914e41de1b021e0ba3b21c255
7
- data.tar.gz: 93e200fcc81563e5f6a9699067c6543c4e4b26af42f1504dff84aa0a2e59ce89efe2fb2a571dde5fd2bc298bded45b2c9b332d44c1deb79fea22ce4f0130248b
6
+ metadata.gz: 07d53ebe3d570ec39d8e2b669492433972789437100dd811b795e5abad902e5d921fc93cf6714c3fef5f5a484cbcdedab04280becea9ec02fb2e65e762517beb
7
+ data.tar.gz: 604fb6bc76e23853ca980b1f25861eab8ee45bfd2f0d66182d805db8b133fe411d35e485b44042937114236a040e5deb9563f248a703f245cedcc86d58b40cdc
data/README.md CHANGED
@@ -6,7 +6,6 @@ Fuik (Dutch for fish trap) is a Rails engine that catches and stores webhooks fr
6
6
 
7
7
  <img alt="Fuik admin interface" src="https://raw.githubusercontent.com/Rails-Designer/fuik/HEAD/.github/docs/webhooks-index.jpg" style="max-width: 100%;">
8
8
 
9
-
10
9
  **Sponsored By [Rails Designer](https://railsdesigner.com/)**
11
10
 
12
11
  <a href="https://railsdesigner.com/" target="_blank">
@@ -58,7 +57,17 @@ Visit `/webhooks` to see all received webhooks. Click any event to view all the
58
57
 
59
58
  <img alt="Fuik event detail interface" src="https://raw.githubusercontent.com/Rails-Designer/fuik/HEAD/.github/docs/event-detail.jpg" style="max-width: 100%;">
60
59
 
61
- ⚠️ The `/webhooks` path is by default not protected. Easiest is to set `Fuik::Engine.config.events_controller_parent` to a controller that requires authentication.
60
+ ⚠️ The `/webhooks` path is by default not protected. Set `Fuik.configuration.events_controller_parent` to a controller that requires authentication.
61
+
62
+
63
+ ### Dashboard features
64
+
65
+ * **Copy payload as JSON**: click a button, payload is in your clipboard
66
+ * **Download payload as JSON file**: keep it for testing, debugging or throw it at your LLM agent, bot or colleague
67
+ * **Add `.json` to any URL**: get the raw payload without the UI
68
+ * **`GET /events.json`**: index as JSON with filter support
69
+ * **Click any key to get the Ruby accessor path**: click any key and get, e.g. `payload["line_items"][0]["product_id"]` (say what? 🤯)
70
+
62
71
 
63
72
 
64
73
  ### Add business logic
@@ -124,64 +133,192 @@ end
124
133
  If `Provider::Base.verify!` exists, Fuik calls it automatically. Invalid signatures return 401 without storing the webhook.
125
134
 
126
135
 
136
+ ### Configuration
137
+
138
+ Configure Fuik in `config/initializers/fuik.rb`:
139
+ ```ruby
140
+ Fuik.configure do |config|
141
+ # config.events_controller_parent = "AdministrationController"
142
+ # config.providers_allowed = %w[stripe github postmark]
143
+ end
144
+ ```
145
+
146
+
147
+ ### Custom job class
148
+
149
+ Webhook events are processed asynchronously by `Fuik::WebhookProcessingJob`. When processing raises, the event is marked as `failed` so you can retry it from the dashboard.
150
+
151
+ Define a custom job class, then configure Fuik to use it:
152
+ ```ruby
153
+ # app/jobs/webhook_processing_job.rb
154
+ class WebhookProcessingJob < ApplicationJob
155
+ queue_as :webhooks
156
+
157
+ retry_on MyApp::WebhookError, attempts: 5
158
+ discard_on ActiveJob::DeserializationError
159
+
160
+ def perform(event_class_name, webhook_event)
161
+ event_class_name.safe_constantize.new(webhook_event).process!
162
+ end
163
+ end
164
+ ```
165
+
166
+ ```ruby
167
+ # config/initializers/fuik.rb
168
+ Fuik.configure do |config|
169
+ config.webhook_processing_job_class = "WebhookProcessingJob"
170
+ end
171
+ ```
172
+
173
+ The `perform` method receives two arguments:
174
+ - `event_class_name`: a string like `"Stripe::CheckoutSessionCompleted"` that resolves to your event class
175
+ - `webhook_event`: the `Fuik::WebhookEvent` record with the payload, headers and status
176
+
177
+
178
+ ### Testing event classes
179
+
180
+ Unit test your `process!` methods without a database using the built-in test helper:
181
+ ```ruby
182
+ # test/test_helper.rb
183
+ require "fuik/test_helpers"
184
+
185
+ class ActiveSupport::TestCase
186
+ include Fuik::TestHelpers
187
+ end
188
+ ```
189
+
190
+ ```ruby
191
+ # test/webhooks/stripe/checkout_session_completed_test.rb
192
+ module Stripe
193
+ class CheckoutSessionCompletedTest < ActiveSupport::TestCase
194
+ test "processes a completed checkout" do
195
+ event = build_webhook_event(
196
+ provider: "stripe",
197
+ event_type: "checkout.session.completed",
198
+ payload: { "client_reference_id" => "user_123" }
199
+ )
200
+
201
+ CheckoutSessionCompleted.new(event).process!
202
+
203
+ assert_equal "processed", event.status
204
+ end
205
+
206
+ test "fails when customer is not found" do
207
+ event = build_webhook_event(
208
+ provider: "stripe",
209
+ event_type: "checkout.session.completed",
210
+ payload: {}
211
+ )
212
+
213
+ CheckoutSessionCompleted.new(event).process!
214
+
215
+ assert_equal "failed", event.status
216
+ assert_equal "Customer not found", event.error
217
+ end
218
+ end
219
+ end
220
+ ```
221
+
222
+ Your `process!` calls `@webhook_event.processed!` on success and `@webhook_event.failed!(error)` on failure:
223
+ ```ruby
224
+ module Stripe
225
+ class CheckoutSessionCompleted < Base
226
+ def process!
227
+ user = User.find_by(id: payload.client_reference_id)
228
+
229
+ if user.present?
230
+ user.activate_subscription!
231
+
232
+ @webhook_event.processed!
233
+ else
234
+ @webhook_event.failed!("Customer not found")
235
+ end
236
+ end
237
+ end
238
+ end
239
+ ```
240
+
241
+
127
242
  ### Provider allowlist
128
243
 
129
244
  By default:
130
245
  - **Development/test**: all providers are allowed
131
246
  - **Production/staging**: only providers in `app/webhooks/` are allowed
132
247
 
133
- Configure with `Fuik::Engine.config.providers_allowed`:
248
+ Configure with `Fuik.configuration.providers_allowed`:
134
249
  ```ruby
135
250
  # Allow all (including production)
136
- Fuik::Engine.config.providers_allowed = :all
251
+ Fuik.configuration.providers_allowed = :all
137
252
 
138
253
  # Explicit allowlist (overrides directory scan)
139
- Fuik::Engine.config.providers_allowed = %w[stripe github shopify]
254
+ Fuik.configuration.providers_allowed = %w[stripe github postmark]
140
255
  ```
141
256
 
142
257
  Unknown providers return `404 Not Found`.
143
258
 
144
259
 
145
- ### Pre-packaged providers
146
-
147
- Fuik includes ready-to-use [templates for common providers](https://github.com/Rails-Designer/fuik/tree/main/lib/generators/fuik/provider/templates).
148
-
149
-
150
260
  ### Event type & ID lookup
151
261
 
152
262
  Fuik automatically extracts event types and IDs from common locations:
153
263
 
154
264
  **Event Type:**
155
- 1. provider config (if exists);
265
+ 1. provider Base class (if configured);
156
266
  2. common headers (`X-Github-Event`, `X-Event-Type`, etc.);
157
267
  3. payload (`type`, `event`, `event_type`);
158
268
  4. falls back to `"unknown"`.
159
269
 
160
270
  **Event ID:**
161
- 1. provider config (if exists);
271
+ 1. provider Base class (if configured);
162
272
  2. common headers (`X-GitHub-Delivery`, `X-Event-Id`, etc.);
163
273
  3. payload (`id`).
164
274
  4. falls back to MD5 hash of request body.
165
275
 
166
276
 
167
- #### Custom lookup via config
277
+ #### Custom lookup
168
278
 
169
- Create `app/webhooks/provider_name/config.yml`:
170
- ```yaml
171
- event_type:
172
- source: header
173
- key: X-Custom-Event
279
+ Define `event_type` and/or `event_id` on your provider's Base class if Fuik can't find them on its own:
280
+ ```ruby
281
+ module CustomProvider
282
+ class Base < Fuik::Event
283
+ event_type header: "X-Custom-Event"
284
+ event_id header: "X-Custom-Id"
285
+ end
286
+ end
287
+ ```
174
288
 
175
- event_id:
176
- source: payload
177
- key: custom_id
289
+ You can also read from the payload body or use a static value:
290
+ ```ruby
291
+ event_type payload: "event_type" # payload["event_type"]
292
+ event_type payload: "data.type" # nested via dot-notation (payload["data"]["type"])
293
+ event_type "always_this_value" # static/literal
294
+
295
+ event_id payload: "id"
296
+ ```
297
+
298
+
299
+ ### Pre-packaged providers
300
+
301
+ Fuik includes ready-to-use [templates for common providers](https://github.com/Rails-Designer/fuik/tree/main/lib/generators/fuik/provider/templates).
302
+
303
+
304
+ ## Monitoring
305
+
306
+ Fuik publishes lifecycle events via `ActiveSupport::Notifications`. Subscribe to track webhook activity:
307
+ ```ruby
308
+ ActiveSupport::Notifications.subscribe("webhook_received.fuik") do |event|
309
+ Rails.logger.info("[Fuik] Received #{event.payload[:provider]}:#{event.payload[:event_type]}")
310
+ end
178
311
  ```
179
312
 
180
- The options for `event_type`'s source are:
313
+ Available events:
181
314
 
182
- - header
183
- - payload
184
- - static; for cases when no event type is present in header or payload
315
+ | Event | When it fires |
316
+ |---|---|
317
+ | `webhook_received.fuik` | Event record created |
318
+ | `webhook_processed.fuik` | `process!` completed |
319
+ | `webhook_failed.fuik` | `process!` raised |
320
+ | `webhook_signature_invalid.fuik` | Signature verification failed (401) |
321
+ | `webhook_receive_error.fuik` | Unexpected error during receive (500) |
185
322
 
186
323
 
187
324
  ## Add your custom provider
@@ -0,0 +1 @@
1
+ !function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).Attractive=e()}(this,(function(){"use strict";class t{static enabled=!1;static prefix="🧲 ";static log(...t){this.enabled&&console.log(this.prefix,...t)}static warn(...t){this.enabled&&console.warn(this.prefix,...t)}static error(...t){this.enabled&&console.error(this.prefix,...t)}static throw(t){throw new Error(`${this.prefix}${t}`)}}class e{#t;constructor(e){t.log("Events with actions:",Object.keys(e)),this.#t=e}process(t,{on:e,using:s}){e&&this.#e(e.dataset.action).some((r=>!1===this.#s(r,{for:t,on:e,using:s})))}#e(t){return t.split(" ").filter((t=>t))}#s(e,{for:s,on:r,using:i}){if(t.log("Process action for",s.type,"on",r,"…"),e.includes(":")){const[t,r]=e.split(":");if(s.type!==r)return;e=t}else if(e.includes("->")){const[t,r]=e.split("->");if((t.includes("@")?t.split("@")[1]:t)!==s.type)return;e=r}else if(s.type!==i)return;return t.log("…","processed action for",s.type,"on",r),this.#r(e,{on:r,for:s})}#r(e,{on:s,for:r}){t.log("Execute action",e,"on",s,"…");const i=e.split("#"),[n,a,o]=i,c=this.#t[n]?n:a??e;if("function"!=typeof this.#t[c])return;const l=this.#t[n]?i.slice(1).join("#"):o??null,u=this.#t[c](s,{value:l,target:s.dataset.target,targets:s.dataset.targets});return!1===u&&r&&r.preventDefault(),t.log("…","executed action",e,"on",s),u}}var s=new class{identify({by:t}){return t.split(" ").filter((t=>t.includes("->"))).map((t=>t.split("->")[0]))}getDefault({from:t}){const e=t.tagName.toLowerCase(),s="input"===e,r=s?t.type||"text":null;return s?this.#i.input[r]||this.#i.input.default:this.#i[e]||this.#i.default}#i={a:"click",button:"click",input:{checkbox:"change",radio:"change",submit:"click",button:"click",reset:"click",default:"input"},select:"change",textarea:"input",form:"submit",default:"click"}};class r{#n;#a;#o;constructor(t,e=null){this.#n=t,this.#a=e}start(t){if(window.MutationObserver)return this.#o=new MutationObserver((e=>{const s=new Set,r=new Set;e.forEach((e=>this.#c(e,{for:t,elements:{added:s,removed:r}}))),s.forEach((t=>this.#n(t))),this.#a&&r.forEach((t=>this.#a(t)))})),this.#o.observe(document.documentElement,{childList:!0,subtree:!0}),this}stop(){return this.#o&&this.#o.disconnect(),this}#c(t,{for:e,elements:{added:s,removed:r}}){"childList"===t.type&&(t.addedNodes.forEach((t=>{this.#l(t,{for:e,and:s})})),t.removedNodes.forEach((t=>{this.#l(t,{for:e,and:r})})))}#l(t,{for:e,and:s}){t.nodeType===Node.ELEMENT_NODE&&(t.matches&&t.matches(e)&&s.add(t),t.querySelectorAll&&t.querySelectorAll(e).forEach((t=>s.add(t))))}}var i=new class{#u={mounted:(t,e)=>{e()},now:(t,e)=>{e()},whenVisible:(t,e)=>{const s=new IntersectionObserver((t=>{t.forEach((t=>{t.isIntersecting&&(e(),s.disconnect())}))}));s.observe(t)}};setup({for:t,on:e,trigger:s}){const r=this.#u[t];return!!r&&(r(e,s),!0)}};class n{constructor(t,e={}){if(!t)throw new Error("Current element is required");this.currentElement=t,this.target=e.target,this.targetsSelector=e.targets,this.options=e}get targets(){if(this.targetsSelector)return Array.from(document.querySelectorAll(this.targetsSelector));if(this.target){const t=document.getElementById(this.target);return t?[t]:[]}return[this.currentElement]}cycledValue(t,e){const s=Array.isArray(e)?e:e.split(",").map((t=>t.trim())),r=s.indexOf(t);return s[(r+1)%s.length]}}class a extends n{constructor(t,e={}){super(t,e);const[s,r]=e.value.split("=");this.attribute=s,this.value=r}toggle(){this.attribute&&this.targets.forEach((t=>t.hasAttribute(this.attribute)?t.removeAttribute(this.attribute):t.setAttribute(this.attribute,this.value||"")))}cycle(){this.value&&this.targets.forEach((t=>this.#h(t)))}add(){this.attribute&&this.targets.forEach((t=>t.setAttribute(this.attribute,this.value||"")))}remove(){this.attribute&&this.targets.forEach((t=>t.removeAttribute(this.attribute)))}#h(t){const e=this.cycledValue(t.getAttribute(this.attribute),this.value);t.setAttribute(this.attribute,e)}}const o=t=>(e,s={})=>new a(e,s)[t]();var c={toggleAttribute:o("toggle"),cycleAttribute:o("cycle"),addAttribute:o("add"),removeAttribute:o("remove")};const l=t=>t?.split(",").map((t=>t.trim())).filter(Boolean)??[];class u extends n{constructor(t,e={}){super(t,e),this.value=l(e.value)}toggle(){this.value&&this.targets.forEach((t=>this.#d({forEach:t})))}cycle(){this.value&&0!==this.value.length&&this.targets.forEach((t=>this.#f(t)))}add(){this.value&&this.targets.forEach((t=>t.classList.add(...this.value)))}remove(){this.value&&this.targets.forEach((t=>t.classList.remove(...this.value)))}#d({forEach:t}){this.value.forEach((e=>t.classList.toggle(e)))}#f(t){const e=this.value.find((e=>t.classList.contains(e)))||"",s=this.cycledValue(e,this.value);t.classList.remove(...this.value),t.classList.add(s)}}const h=t=>(e,s={})=>new u(e,s)[t]();var d={toggleClass:h("toggle"),cycleClass:h("cycle"),addClass:h("add"),removeClass:h("remove")};let f;const g=(t,e)=>{clearTimeout(f),f=setTimeout(t,e)};class v extends n{constructor(t,e={}){super(t,e),this.value=e.value}async copy(){const t=this.value||(this.targets[0]?.value??this.targets[0]?.textContent);if(void 0!==t)try{await navigator.clipboard.writeText(t),this.#g(!0)}catch(t){this.#g(!1)}}#g(t){const e=parseInt(this.currentElement.dataset.copyDelay);this.targets.forEach((e=>e.setAttribute(this.#v,t))),e&&g((()=>this.targets.forEach((t=>t.removeAttribute(this.#v)))),e)}get#v(){return"data-copy-success"}}var m,p={copy:(m="copy",(t,e={})=>new v(t,e)[m]())};class b extends n{confirm(){const t=this.currentElement.dataset.confirmMessage||"Are you sure?",e=window.confirm(t);return this.#g(e),e}#g(t){this.currentElement.setAttribute("data-confirm-success",t);const e=this.currentElement.dataset.confirmDuration;e&&g((()=>this.currentElement.removeAttribute("data-confirm-success")),parseInt(e))}}var E={confirm:(t=>(e,s={})=>new b(e,s)[t]())("confirm")};class w extends n{constructor(t,e={}){super(t,e);const[s,r]=e.value.split("=");this.attribute=s,this.value=r}toggle(){this.attribute&&this.targets.forEach((t=>{this.attribute in t.dataset?delete t.dataset[this.attribute]:t.dataset[this.attribute]=this.value||""}))}cycle(){this.value&&this.targets.forEach((t=>this.#m(t)))}add(){this.attribute&&this.targets.forEach((t=>{t.dataset[this.attribute]=this.value||""}))}remove(){this.attribute&&this.targets.forEach((t=>delete t.dataset[this.attribute]))}#m(t){const e=this.cycledValue(t.dataset[this.attribute],this.value);t.dataset[this.attribute]=e}}const y=t=>(e,s={})=>new w(e,s)[t]();var A={toggleDataAttribute:y("toggle"),cycleDataAttribute:y("cycle"),addDataAttribute:y("add"),removeDataAttribute:y("remove")};class L extends n{open(){this.targets.forEach((t=>t instanceof HTMLDialogElement&&t.show()))}openModal(){this.targets.forEach((t=>t instanceof HTMLDialogElement&&t.showModal()))}close(){this.targets.forEach((t=>t instanceof HTMLDialogElement&&t.close()))}}const T=t=>(e,s={})=>{new L(e,s)[t]()};var x={open:T("open"),openModal:T("openModal"),close:T("close")};class M extends n{add(){const t=this.targets[0];if(!t)return;const e=this.currentElement.dataset.addSource;if(!e)return;const s=document.querySelector(e);if(!s)return;const r=this.#p(s),i=this.currentElement.dataset.addAt||"beforeend";t.insertAdjacentElement(i,r)}remove(){const t=parseInt(this.currentElement.dataset.removeDelay),e=()=>{this.targets.forEach((t=>t.remove()))};t?setTimeout(e,t):e()}#p(t){return"TEMPLATE"===t.tagName?t.content.cloneNode(!0).firstElementChild:t.cloneNode(!0)}}const S=t=>(e,s={})=>new M(e,s)[t]();var k={add:S("add"),remove:S("remove")};class N extends n{requestSubmit(){const t=parseInt(this.currentElement.dataset.submitDelay)||0,e=()=>this.targets.forEach((t=>t instanceof HTMLFormElement&&t.requestSubmit()));t?g(e,t):e()}reset(){this.targets.forEach((t=>t instanceof HTMLFormElement&&t.reset()))}}const F=t=>(e,s={})=>{new N(e,s)[t]()};var D={submit:F("requestSubmit"),reset:F("reset")};class q extends n{constructor(t,e={}){super(t,e),this.classNames=l(e.value),this.justOnce="intersect-once"===e.actionName}start(){if(0===this.classNames.length)return;const t=new IntersectionObserver((e=>{e.forEach((e=>{e.isIntersecting?this.#b(t,e.target):this.#E()}))}));t.observe(this.currentElement)}#b(t,e){this.targets.forEach((t=>t.classList.add(...this.classNames))),this.justOnce&&t.unobserve(e)}#E(){this.justOnce||this.targets.forEach((t=>t.classList.remove(...this.classNames)))}}const C=t=>(e,s={})=>new q(e,{...s,actionName:t}).start();var O={"intersect-once":C("intersect-once"),"intersect-toggle":C("intersect-toggle")};class I extends n{reload(){this.targets.forEach((t=>{this.#w(t)?t.reload():window.location.reload()}))}#w(t){return"TURBO-FRAME"===t.tagName&&"function"==typeof t.reload}}const H=t=>(e,s={})=>new I(e,s)[t]();var V={reload:H("reload"),refresh:H("reload")};class j{static get(){const t=document.querySelector('meta[name="csrf-token"]')?.content;return t||null}}class $ extends n{constructor(t,e={}){super(t,e),this.value=e.value}async get(){if(this.value){this.#y({with:this.value})&&console.warn(`Cross-origin request to: ${this.value}. Missing the correct CORS headers.`),this.#g("busy");try{const t=await fetch(this.value,{method:"GET"});if(!t.ok)throw new Error(`HTTP error! status: ${t.status}`);const e=await t.text();return this.targets.forEach((t=>{t.innerHTML=e})),this.#g("success"),t}catch(t){throw console.error("GET request failed:",t),this.#g("error"),t}}else console.warn("No URL provided in the action value")}post(){return this.#A("POST")}patch(){return this.#A("PATCH")}put(){return this.#A("PUT")}#y({with:t}){try{return new URL(t,window.location.href).origin!==window.location.origin}catch{return console.error("Invalid URL:",t),!1}}#g(t){const e=this.currentElement.dataset.requestDuration;this.targets.forEach((e=>{"busy"===t?(e.setAttribute("data-request-busy","true"),e.removeAttribute("data-request-success")):(e.removeAttribute("data-request-busy"),e.setAttribute("data-request-success","success"===t))})),e&&"busy"!==t&&g((()=>{this.targets.forEach((t=>t.removeAttribute("data-request-success")))}),parseInt(e))}#A(t){if(this.value)return this.#g("busy"),fetch(this.value,{method:t,headers:{"Content-Type":"application/json","X-CSRF-Token":j.get()},body:JSON.stringify(this.#L)}).then((t=>{if(!t.ok)throw new Error(`HTTP error! status: ${t.status}`);return this.#g("success"),t})).catch((e=>{throw console.error(`${t} request failed:`,e),this.#g("error"),e}));console.warn("No URL provided in the action value")}get#L(){const t={},e=this.targets[0];if(e instanceof HTMLFormElement){const s=new FormData(e);for(const[e,r]of s.entries())t[e]=r;return t}if(this.#T){const e=this.currentElement.name,s=this.currentElement.value;t[e]=s}return t}get#T(){return this.currentElement instanceof HTMLInputElement||this.currentElement instanceof HTMLSelectElement||this.currentElement instanceof HTMLTextAreaElement&&this.currentElement.name}}const R=t=>(e,s={})=>new $(e,s)[t]();var P={get:R("get"),post:R("post"),patch:R("patch"),put:R("put")};class U extends n{constructor(t,e={}){super(t,e);const s=e.value;this.behavior=["auto","instant","smooth"].includes(s)?s:"auto"}scroll(){this.targets[0]?.scrollIntoView({behavior:this.behavior})}}var z={scrollTo:(t=>(e,s={})=>new U(e,s)[t]())("scroll")};const B={attribute:c,class:d,clipboard:p,confirm:E,dataAttribute:A,dialog:x,element:k,form:D,intersection:O,reload:V,request:P,scrollTo:z},G=(t=[])=>0===t.length?Object.values(B).reduce(((t,e)=>({...t,...e})),{}):B.reduce(((e,s)=>t[s]?{...e,...t[s]}:(console.warn(`Action “${s}” not found`),e)),{});var _=G([]);var J=new class{#x={};#M=new WeakMap;#S=new Map;#k;#N=new Map;#F;static get debug(){return t.enabled}static set debug(e){t.enabled=e}constructor(){this.#k=new e(_),this.#F=new r((t=>this.#n(t)),(t=>this.#a(t)))}activate(e={}){const{on:s=document,debug:r=!1}=e;return t.enabled=r,t.log("Initializing…"),this.element=s,this.#F.start("[data-action]"),this.element.querySelectorAll("[data-action]").forEach((t=>this.#n(t))),t.log("…initialized"),this}withActions(s=[]){return t.log("Initializing with actions",s),this.#k=new e(G(s)),this}#n(t){const e=t.dataset.action;if(!e)return;const r=e.split(" ");new Set(e.includes("->")?s.identify({by:e}):[s.getDefault({from:t})]).forEach((t=>this.#D({for:t}))),r.filter((t=>t.includes("@"))).forEach((e=>{const[s]=e.split("->"),[r,i]=s.split("@"),n="window"===r?window:document;this.#q(i,n,t)}));const n=r.filter((t=>t.includes(":"))).map((t=>t.split(":")[1]));n.forEach((e=>{i.setup({for:e,on:t,trigger:()=>this.#k.process({type:e},{on:t,using:e})})}))}#a(t){const e=this.#M.get(t);e&&(e.forEach((e=>{const s=this.#N.get(e);if(s&&(s.delete(t),0===s.size)){const[t,s]=e.split(":"),r=this.#S.get(e);("window"===t?window:document).removeEventListener(s,r),this.#S.delete(e),this.#N.delete(e)}})),this.#M.delete(t))}#D({for:e}){this.#x[e]||(this.element.addEventListener(e,(t=>this.#C(t))),t.log("Added event listener for",e,"to",this.element),this.#x[e]=!0)}#q(t,e,s){const r=`${e===window?"window":"document"}:${t}`;if(this.#M.has(s)||this.#M.set(s,new Set),this.#M.get(s).add(r),this.#N.has(r)||this.#N.set(r,new Set),this.#N.get(r).add(s),!this.#S.has(r)){const s=e=>{const s=this.#N.get(r);s&&s.forEach((s=>{this.#k.process(e,{on:s,using:t})}))};e.addEventListener(t,s),this.#S.set(r,s)}}#C(t){const e=t.target.closest("[data-action]");if(!e)return;const r=s.getDefault({from:e});this.#k.process(t,{on:e,using:r})}};const W=(t=document)=>{window.Attractive&&window.Attractive._initialized||J.activate({element:t})};return"undefined"!=typeof document&&("loading"===document.readyState?document.addEventListener("DOMContentLoaded",(()=>W())):W(),document.addEventListener("turbo:load",(()=>W()))),"undefined"!=typeof window&&(window.Attractive=J),J}));
@@ -2,10 +2,17 @@
2
2
 
3
3
  @layer base {
4
4
  :root {
5
- --color-text: oklch(30% 0.02 250);
6
- --color-text-muted: oklch(55% 0.02 250);
7
- --color-border: oklch(90% 0.01 250);
8
- --color-bg-hover: oklch(97% 0.01 250);
5
+ --gray-50: oklch(98.7% .002 197.1);
6
+ --gray-100: oklch(96.3% .002 197.1);
7
+ --gray-200: oklch(92.5% .005 214.3);
8
+ --gray-300: oklch(87.2% .007 219.6);
9
+ --gray-400: oklch(72.3% .014 214.4);
10
+ --gray-500: oklch(56% .021 213.5);
11
+ --gray-600: oklch(45% .017 213.2);
12
+ --gray-700: oklch(37.8% .015 216);
13
+ --gray-800: oklch(27.5% .011 216.9);
14
+ --gray-900: oklch(21.8% .008 223.9);
15
+ --gray-950: oklch(14.8% .004 228.8);
9
16
  }
10
17
 
11
18
  * { box-sizing: border-box; margin: 0; padding: 0; }
@@ -13,7 +20,8 @@
13
20
  body {
14
21
  font-family: system-ui, -apple-system, sans-serif;
15
22
  line-height: 1.5;
16
- color: var(--color-text);
23
+ color: light-dark(var(--gray-800), var(--gray-200));
24
+ background-color: light-dark(white, var(--gray-950));
17
25
  }
18
26
 
19
27
  main {
@@ -22,19 +30,6 @@
22
30
  margin-inline: auto;
23
31
  }
24
32
 
25
- h1 {
26
- margin-block-end: 1rem;
27
- font-size: 1.875rem;
28
- font-weight: 700;
29
- letter-spacing: -.025em;
30
-
31
- a {
32
- color: var(--color-text-muted);
33
-
34
- &:hover { color: var(--color-text); }
35
- }
36
- }
37
-
38
33
  a {
39
34
  color: inherit;
40
35
  text-decoration: none;
@@ -47,60 +42,38 @@
47
42
  padding-block: .5rem;
48
43
  padding-inline: 1rem;
49
44
  max-height: calc(20lh + 1rem);
45
+ width: 100%;
50
46
  font-size: .875rem;
51
- background: var(--color-bg-hover);
52
- border-radius: .5rem;
47
+ background: light-dark(var(--gray-50), var(--gray-800));
48
+ border: 1px solid light-dark(transparent, var(--gray-700));
49
+ border-radius: .375rem;
53
50
  overflow-x: auto;
54
51
  }
52
+ }
55
53
 
56
- dl {
57
- display: grid;
58
- grid-template-columns: auto 1fr;
59
- gap: 1rem 2rem;
60
- overflow: hidden;
61
-
62
- dt {
63
- font-weight: 600;
64
- color: var(--color-text);
65
- }
66
-
67
- dd {
68
- position: relative;
69
- overflow-x: auto;
70
- }
71
-
72
- .actions {
73
- display: flex;
74
- align-items: center;
75
- gap: .5rem;
76
- position: absolute;
77
- top: .5rem; right: .5rem;
78
- }
79
-
80
- svg {
81
- width: 1rem;
82
- aspect-ratio: 1 / 1;
83
- color: oklch(from var(--color-text) l c h / 80%);
84
- background: var(--color-bg);
85
- }
54
+ @layer components {
55
+ .heading {
56
+ margin-block-end: 1rem;
57
+ font-size: 1.875rem;
58
+ font-weight: 550;
59
+ letter-spacing: -.0125em;
60
+ color: light-dark(var(--gray-700), var(--gray-100));
86
61
 
87
- dd .copy-button .success-icon {
88
- display: none;
62
+ .divider {
63
+ font-weight: 400;
64
+ color: light-dark(var(--gray-600), var(--gray-300));
89
65
  }
90
66
 
91
- dd:has([data-copy-success="true"]) .copy-button .copy-icon {
92
- display: none;
93
- }
67
+ a {
68
+ color: light-dark(var(--gray-500), var(--gray-400));
94
69
 
95
- dd:has([data-copy-success="true"]) .copy-button .success-icon {
96
- display: block;
70
+ &:hover { color: light-dark(var(--gray-800), var(--gray-200)); }
97
71
  }
98
72
  }
99
- }
100
73
 
101
- @layer components {
102
74
  .events {
103
75
  list-style: none;
76
+ margin-block-start: 1rem;
104
77
 
105
78
  li {
106
79
  --icon-width: 1.125rem;
@@ -108,7 +81,7 @@
108
81
  padding-block: .5rem;
109
82
  padding-inline: .75rem;
110
83
 
111
- &:hover { background: var(--color-bg-hover); }
84
+ &:hover { background: light-dark(var(--gray-50), var(--gray-800)); }
112
85
 
113
86
  a {
114
87
  display: grid;
@@ -118,6 +91,12 @@
118
91
  &:hover { text-decoration: none; }
119
92
  }
120
93
 
94
+ .icon {
95
+ width: var(--icon-width);
96
+ aspect-ratio: 1/1;
97
+ border-radius: .15rem;
98
+ }
99
+
121
100
  .name {
122
101
  display: grid;
123
102
  grid-template-columns: var(--icon-width) 1fr;
@@ -125,12 +104,6 @@
125
104
  gap: .5rem;
126
105
  }
127
106
 
128
- .icon {
129
- width: var(--icon-width);
130
- aspect-ratio: 1/1;
131
- border-radius: .125rem;
132
- }
133
-
134
107
  code {
135
108
  display: flex;
136
109
  align-items: center;
@@ -138,7 +111,16 @@
138
111
  }
139
112
 
140
113
  li:not(:first-child) {
141
- border-top: 1px solid var(--color-border);
114
+ border-top: 1px solid light-dark(var(--gray-100), var(--gray-800));
115
+ }
116
+
117
+ .empty-state {
118
+ display: none;
119
+ font-size: .875rem;
120
+ font-weight: 300;
121
+ color: light-dark(var(--gray-400), var(--gray-500));
122
+
123
+ &:only-child { display: block; }
142
124
  }
143
125
  }
144
126
 
@@ -152,7 +134,7 @@
152
134
  width: .5rem;
153
135
  aspect-ratio: 1/1;
154
136
  background-color: currentColor;
155
- border: 2px solid oklch(from currentColor 1 c h / 85%);
137
+ border: 1.75px solid oklch(from currentColor .9 c h / .8);
156
138
  border-radius: .5rem;
157
139
  }
158
140
 
@@ -163,39 +145,180 @@
163
145
  &[data-status="failed"]::before { color: oklch(60% .2 25); }
164
146
  }
165
147
 
148
+ .filters {
149
+ display: flex;
150
+ align-items: center;
151
+ gap: 2rem;
152
+
153
+ fieldset {
154
+ border: 0;
155
+
156
+ legend {
157
+ font-size: .875rem;
158
+ font-weight: 400;
159
+ color: light-dark(var(--gray-500), var(--gray-400));
160
+ }
161
+
162
+ input[type="radio"] {
163
+ position: absolute;
164
+ clip: rect(0, 0, 0, 0);
165
+ }
166
+
167
+ .pills {
168
+ display: inline-flex;
169
+ background-color: light-dark(var(--gray-50), var(--gray-800));
170
+ border: 1px solid light-dark(var(--gray-200), var(--gray-700));
171
+ border-radius: .3rem;
172
+ overflow: clip;
173
+
174
+ .pill {
175
+ display: inline-flex;
176
+ align-items: center;
177
+ padding: .25rem .75rem;
178
+ font-size: .875rem;
179
+ font-weight: 400;
180
+ border-inline-start: 1px solid light-dark(var(--gray-200), var(--gray-700));
181
+ cursor: pointer;
182
+ transition: all ease-in-out 100ms;
183
+
184
+ &:first-child { border-inline-start: 0; }
185
+
186
+ &:has(input:checked),
187
+ &:hover:not(:has(input:checked)) {
188
+ background-color: light-dark(var(--gray-100), var(--gray-700));
189
+ }
190
+ }
191
+ }
192
+ }
193
+
194
+ select {
195
+ padding: .4rem .75rem;
196
+ font-size: .875rem;
197
+ background-color: light-dark(var(--gray-50), var(--gray-800));
198
+ border: 1px solid light-dark(var(--gray-200), var(--gray-700));
199
+ border-radius: .25rem;
200
+ cursor: pointer;
201
+ }
202
+
203
+ [type=submit] { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border-width: 0; }
204
+ }
205
+
206
+ .details {
207
+ display: grid;
208
+ grid-template-columns: auto 1fr;
209
+ gap: 1rem 2rem;
210
+ overflow: hidden;
211
+
212
+ .description {
213
+ display: flex;
214
+ align-items: baseline;
215
+ margin-block-start: .125em;
216
+ font-weight: 500;
217
+ color: light-dark(var(--gray-700), var(--gray-400));
218
+ }
219
+
220
+ .icon {
221
+ width: 1.125rem;
222
+ aspect-ratio: 1/1;
223
+ margin-right: .25rem;
224
+ border-radius: .15rem;
225
+ }
226
+
227
+ .value {
228
+ position: relative;
229
+ display: flex;
230
+ align-items: center;
231
+ overflow-x: auto;
232
+
233
+ button { margin-left: .25rem; }
234
+ }
235
+
236
+ .actions {
237
+ display: flex;
238
+ align-items: center;
239
+ gap: .5rem;
240
+ position: absolute;
241
+ top: .5rem; right: .5rem;
242
+ }
243
+
244
+ svg {
245
+ width: 1rem;
246
+ aspect-ratio: 1 / 1;
247
+ color: light-dark(var(--gray-800), var(--gray-200));
248
+ }
249
+
250
+ dd .copy-button .success-icon {
251
+ display: none;
252
+ }
253
+
254
+ dd:has([data-copy-success="true"]) .copy-button .copy-icon {
255
+ display: none;
256
+ }
257
+
258
+ dd:has([data-copy-success="true"]) .copy-button .success-icon {
259
+ display: block;
260
+ }
261
+ }
262
+
166
263
  button,
167
264
  .copy-button {
168
265
  padding: .125rem;
169
- background: var(--color-bg);
266
+ background: light-dark(var(--gray-50), var(--gray-800));
170
267
  border: 0;
171
268
  border-radius: .25rem;
172
269
  cursor: pointer;
173
270
 
174
271
  &:hover {
175
- color: oklch(from var(--color-text) l c h);
272
+ color: light-dark(var(--gray-800), var(--gray-400));
176
273
  transition: transform ease-in-out 300ms;
177
- transform: scale(1.10);
274
+
275
+ & svg { transform: scale(1.05); }
178
276
  }
179
277
  }
180
278
 
279
+ .retry-button {
280
+ display: inline-flex;
281
+ align-items: center;
282
+ gap: .25rem;
283
+ margin-inline-start: .5rem;
284
+ padding: .25rem .75rem;
285
+ border: 1px solid light-dark(var(--gray-200), var(--gray-700));
286
+ }
287
+
181
288
  .json-highlight {
182
289
  .json-key {
183
- color: oklch(35% .15 250);
184
- cursor: pointer;
290
+ color: light-dark(oklch(35% .15 250), oklch(70% .2 250));
291
+
292
+ #payload & {
293
+ border-radius: .125rem;
294
+ cursor: pointer;
295
+ }
185
296
 
186
- &:hover {
187
- background: oklch(90% 0.02 250);
297
+ #payload &:hover {
298
+ background: light-dark(oklch(from currentColor l c h / .1), oklch(from currentColor l c h / .2));
188
299
  }
189
300
  }
190
301
 
191
302
  .json-key-selected {
192
- background: oklch(85% 0.05 250);
303
+ background: light-dark(oklch(85% .05 250), oklch(25% .08 250));
193
304
  }
194
305
 
195
- .json-string { color: oklch(30% .18 160); }
196
- .json-number { color: oklch(40% .15 130); }
197
- .json-boolean { color: oklch(45% .18 260); }
198
- .json-null { color: oklch(50% .05 250); }
199
- .json-punctuation { color: oklch(20% .03 250); }
306
+ .json-string { color: light-dark(oklch(30% .18 160), oklch(72% .18 160)); user-select: all; }
307
+ .json-number { color: light-dark(oklch(40% .15 130), oklch(70% .18 130)); }
308
+ .json-boolean { color: light-dark(oklch(45% .18 260), oklch(75% .2 260)); }
309
+ .json-null { color: light-dark(oklch(50% .05 250), oklch(68% .08 250)); }
310
+ .json-punctuation { color: light-dark(oklch(20% .03 250), oklch(60% .03 250)); }
311
+ }
312
+
313
+ .errors {
314
+ padding-block: .5rem;
315
+ padding-inline: 1rem;
316
+ max-height: calc(20lh + 1rem);
317
+ font-size: .875rem;
318
+ color: light-dark(oklch(50.5% .213 27.52), oklch(88.5% .062 18.33));
319
+ background: light-dark(oklch(97.1% .013 17.38), oklch(25.8% .092 26.04 / .5));
320
+ border-radius: .5rem;
321
+ overflow-x: auto;
322
+ font-family: "Courier New", Courier, monospace;
200
323
  }
201
324
  }