From 2d336ed0005e7925856c47788edac88270791328 Mon Sep 17 00:00:00 2001 From: Alphonse Paix Date: Wed, 17 Sep 2025 03:40:23 +0200 Subject: [PATCH] Use HTML swap to display success and error messages --- Cargo.lock | 84 ----------------------------- Cargo.toml | 4 -- assets/css/main.css | 2 +- src/email_client.rs | 2 +- src/lib.rs | 1 + src/routes/admin.rs | 27 ++++++---- src/routes/admin/change_password.rs | 27 +++++----- src/routes/admin/newsletters.rs | 28 ++++------ src/routes/home.rs | 15 ++---- src/routes/login.rs | 30 ++++------- src/routes/subscriptions.rs | 20 ++++--- src/startup.rs | 2 - src/templates.rs | 13 +++++ templates/dashboard.html | 77 +++++++------------------- templates/error.html | 9 ++++ templates/home.html | 9 ++-- templates/login.html | 7 ++- templates/success.html | 9 ++++ 18 files changed, 134 insertions(+), 232 deletions(-) create mode 100644 src/templates.rs create mode 100644 templates/error.html create mode 100644 templates/success.html diff --git a/Cargo.lock b/Cargo.lock index 8a6957a..269776f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -229,32 +229,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "axum-extra" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45bf463831f5131b7d3c756525b305d40f1185b688565648a92e1392ca35713d" -dependencies = [ - "axum", - "axum-core", - "bytes", - "cookie", - "form_urlencoded", - "futures-util", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "rustversion", - "serde", - "serde_html_form", - "serde_path_to_error", - "tower", - "tower-layer", - "tower-service", -] - [[package]] name = "axum-macros" version = "0.5.0" @@ -266,22 +240,6 @@ dependencies = [ "syn", ] -[[package]] -name = "axum-messages" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d67ce6e7bc1e1e71f2a4e86d418045a29c63c4ebb631f3d9bb2f81c4958ea391" -dependencies = [ - "axum-core", - "http", - "parking_lot", - "serde", - "serde_json", - "tower", - "tower-sessions-core", - "tracing", -] - [[package]] name = "axum-server" version = "0.7.2" @@ -1141,12 +1099,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "htmlescape" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9025058dae765dee5070ec375f591e2ba14638c63feff74f13805a72e523163" - [[package]] name = "http" version = "1.3.1" @@ -1464,15 +1416,6 @@ dependencies = [ "serde", ] -[[package]] -name = "keccak" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654" -dependencies = [ - "cpufeatures", -] - [[package]] name = "lazy_static" version = "1.5.0" @@ -2456,19 +2399,6 @@ dependencies = [ "syn", ] -[[package]] -name = "serde_html_form" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d2de91cf02bbc07cde38891769ccd5d4f073d22a40683aa4bc7a95781aaa2c4" -dependencies = [ - "form_urlencoded", - "indexmap", - "itoa", - "ryu", - "serde", -] - [[package]] name = "serde_json" version = "1.0.143" @@ -2534,16 +2464,6 @@ dependencies = [ "digest", ] -[[package]] -name = "sha3" -version = "0.10.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" -dependencies = [ - "digest", - "keccak", -] - [[package]] name = "sharded-slab" version = "0.1.7" @@ -3924,15 +3844,12 @@ dependencies = [ "argon2", "askama", "axum", - "axum-extra", - "axum-messages", "axum-server", "base64 0.22.1", "chrono", "claims", "config", "fake", - "htmlescape", "linkify", "once_cell", "quickcheck", @@ -3944,7 +3861,6 @@ dependencies = [ "serde-aux", "serde_json", "serde_urlencoded", - "sha3", "sqlx", "thiserror", "tokio", diff --git a/Cargo.toml b/Cargo.toml index acf4504..b14c897 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,13 +16,10 @@ anyhow = "1.0.99" argon2 = { version = "0.5.3", features = ["std"] } askama = "0.14.0" axum = { version = "0.8.4", features = ["macros"] } -axum-extra = { version = "0.10.1", features = ["query", "cookie"] } -axum-messages = "0.8.0" axum-server = { version = "0.7.2", features = ["tls-rustls-no-provider"] } base64 = "0.22.1" chrono = { version = "0.4.41", default-features = false, features = ["clock"] } config = "0.15.14" -htmlescape = "0.3.1" rand = { version = "0.9.2", features = ["std_rng"] } reqwest = { version = "0.12.23", default-features = false, features = [ "rustls-tls", @@ -32,7 +29,6 @@ reqwest = { version = "0.12.23", default-features = false, features = [ secrecy = { version = "0.10.3", features = ["serde"] } serde = { version = "1.0.219", features = ["derive"] } serde-aux = "4.7.0" -sha3 = "0.10.8" sqlx = { version = "0.8.6", features = [ "runtime-tokio-rustls", "macros", diff --git a/assets/css/main.css b/assets/css/main.css index c82e799..bf08edb 100644 --- a/assets/css/main.css +++ b/assets/css/main.css @@ -1,2 +1,2 @@ /*! tailwindcss v4.1.13 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-space-x-reverse:0;--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-font-weight:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000}}}@layer theme{:root,:host{--font-sans:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-orange-100:oklch(95.4% .038 75.164);--color-orange-600:oklch(64.6% .222 41.116);--color-green-100:oklch(96.2% .044 156.743);--color-green-500:oklch(72.3% .219 149.579);--color-green-600:oklch(62.7% .194 149.214);--color-green-700:oklch(52.7% .154 150.069);--color-blue-100:oklch(93.2% .032 255.585);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-600:oklch(54.6% .245 262.881);--color-blue-700:oklch(48.8% .243 264.376);--color-indigo-700:oklch(45.7% .24 277.023);--color-purple-100:oklch(94.6% .033 307.174);--color-purple-600:oklch(55.8% .288 302.321);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-900:oklch(21% .034 264.665);--color-white:#fff;--spacing:.25rem;--container-md:28rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--container-6xl:72rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1/.75);--text-sm:.875rem;--text-sm--line-height:calc(1.25/.875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75/1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75/1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25/1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5/2.25);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--radius-md:.375rem;--radius-lg:.5rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.static{position:static}.top-0{top:calc(var(--spacing)*0)}.z-40{z-index:40}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.mx-auto{margin-inline:auto}.mt-1{margin-top:calc(var(--spacing)*1)}.mt-2{margin-top:calc(var(--spacing)*2)}.mt-4{margin-top:calc(var(--spacing)*4)}.mt-8{margin-top:calc(var(--spacing)*8)}.mt-auto{margin-top:auto}.mr-2{margin-right:calc(var(--spacing)*2)}.mr-3{margin-right:calc(var(--spacing)*3)}.mb-2{margin-bottom:calc(var(--spacing)*2)}.mb-4{margin-bottom:calc(var(--spacing)*4)}.mb-6{margin-bottom:calc(var(--spacing)*6)}.mb-8{margin-bottom:calc(var(--spacing)*8)}.ml-1{margin-left:calc(var(--spacing)*1)}.ml-4{margin-left:calc(var(--spacing)*4)}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.h-3{height:calc(var(--spacing)*3)}.h-4{height:calc(var(--spacing)*4)}.h-5{height:calc(var(--spacing)*5)}.h-6{height:calc(var(--spacing)*6)}.h-8{height:calc(var(--spacing)*8)}.h-12{height:calc(var(--spacing)*12)}.h-16{height:calc(var(--spacing)*16)}.min-h-\[60vh\]{min-height:60vh}.min-h-screen{min-height:100vh}.w-3{width:calc(var(--spacing)*3)}.w-4{width:calc(var(--spacing)*4)}.w-5{width:calc(var(--spacing)*5)}.w-6{width:calc(var(--spacing)*6)}.w-8{width:calc(var(--spacing)*8)}.w-12{width:calc(var(--spacing)*12)}.w-16{width:calc(var(--spacing)*16)}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-7xl{max-width:var(--container-7xl)}.max-w-md{max-width:var(--container-md)}.flex-1{flex:1}.flex-shrink-0{flex-shrink:0}.resize{resize:both}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.items-center{align-items:center}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-3{gap:calc(var(--spacing)*3)}.gap-4{gap:calc(var(--spacing)*4)}.gap-6{gap:calc(var(--spacing)*6)}.gap-8{gap:calc(var(--spacing)*8)}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*2)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*2)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*4)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*4)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*6)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*6)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-8>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*8)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*8)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-x-4>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing)*4)*var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing)*4)*calc(1 - var(--tw-space-x-reverse)))}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-gray-200{border-color:var(--color-gray-200)}.border-gray-300{border-color:var(--color-gray-300)}.border-transparent{border-color:#0000}.border-white{border-color:var(--color-white)}.bg-blue-100{background-color:var(--color-blue-100)}.bg-blue-600{background-color:var(--color-blue-600)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-green-100{background-color:var(--color-green-100)}.bg-green-600{background-color:var(--color-green-600)}.bg-orange-100{background-color:var(--color-orange-100)}.bg-purple-100{background-color:var(--color-purple-100)}.bg-white{background-color:var(--color-white)}.bg-gradient-to-r{--tw-gradient-position:to right in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.from-blue-600{--tw-gradient-from:var(--color-blue-600);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.to-indigo-700{--tw-gradient-to:var(--color-indigo-700);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.p-3{padding:calc(var(--spacing)*3)}.p-6{padding:calc(var(--spacing)*6)}.p-8{padding:calc(var(--spacing)*8)}.px-3{padding-inline:calc(var(--spacing)*3)}.px-4{padding-inline:calc(var(--spacing)*4)}.px-6{padding-inline:calc(var(--spacing)*6)}.py-2{padding-block:calc(var(--spacing)*2)}.py-3{padding-block:calc(var(--spacing)*3)}.py-6{padding-block:calc(var(--spacing)*6)}.py-8{padding-block:calc(var(--spacing)*8)}.text-center{text-align:center}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.text-blue-100{color:var(--color-blue-100)}.text-blue-600{color:var(--color-blue-600)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-900{color:var(--color-gray-900)}.text-green-600{color:var(--color-green-600)}.text-orange-600{color:var(--color-orange-600)}.text-purple-600{color:var(--color-purple-600)}.text-white{color:var(--color-white)}.ordinal{--tw-ordinal:ordinal;font-variant-numeric:var(--tw-ordinal,)var(--tw-slashed-zero,)var(--tw-numeric-figure,)var(--tw-numeric-spacing,)var(--tw-numeric-fraction,)}.placeholder-gray-400::placeholder{color:var(--color-gray-400)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a),0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a),0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}@media (hover:hover){.hover\:bg-blue-700:hover{background-color:var(--color-blue-700)}.hover\:bg-gray-100:hover{background-color:var(--color-gray-100)}.hover\:bg-green-700:hover{background-color:var(--color-green-700)}.hover\:bg-white:hover{background-color:var(--color-white)}.hover\:text-blue-500:hover{color:var(--color-blue-500)}.hover\:text-blue-600:hover{color:var(--color-blue-600)}.hover\:text-gray-900:hover{color:var(--color-gray-900)}.hover\:opacity-80:hover{opacity:.8}}.focus\:border-blue-500:focus{border-color:var(--color-blue-500)}.focus\:border-green-500:focus{border-color:var(--color-green-500)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-blue-500:focus{--tw-ring-color:var(--color-blue-500)}.focus\:ring-green-500:focus{--tw-ring-color:var(--color-green-500)}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}@media (min-width:40rem){.sm\:flex-row{flex-direction:row}.sm\:px-6{padding-inline:calc(var(--spacing)*6)}}@media (min-width:48rem){.md\:mt-0{margin-top:calc(var(--spacing)*0)}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:flex-row{flex-direction:row}:where(.md\:space-y-0>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*0)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*0)*calc(1 - var(--tw-space-y-reverse)))}:where(.md\:space-x-6>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing)*6)*var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing)*6)*calc(1 - var(--tw-space-x-reverse)))}}@media (min-width:64rem){.lg\:ml-0{margin-left:calc(var(--spacing)*0)}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:px-8{padding-inline:calc(var(--spacing)*8)}}}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-space-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-gradient-position{syntax:"*";inherits:false}@property --tw-gradient-from{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-via{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-to{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-stops{syntax:"*";inherits:false}@property --tw-gradient-via-stops{syntax:"*";inherits:false}@property --tw-gradient-from-position{syntax:"";inherits:false;initial-value:0%}@property --tw-gradient-via-position{syntax:"";inherits:false;initial-value:50%}@property --tw-gradient-to-position{syntax:"";inherits:false;initial-value:100%}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000} \ No newline at end of file +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-space-x-reverse:0;--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-font-weight:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000}}}@layer theme{:root,:host{--font-sans:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-200:oklch(88.5% .062 18.334);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-orange-100:oklch(95.4% .038 75.164);--color-orange-600:oklch(64.6% .222 41.116);--color-green-50:oklch(98.2% .018 155.826);--color-green-100:oklch(96.2% .044 156.743);--color-green-200:oklch(92.5% .084 155.995);--color-green-500:oklch(72.3% .219 149.579);--color-green-600:oklch(62.7% .194 149.214);--color-green-700:oklch(52.7% .154 150.069);--color-blue-100:oklch(93.2% .032 255.585);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-600:oklch(54.6% .245 262.881);--color-blue-700:oklch(48.8% .243 264.376);--color-indigo-700:oklch(45.7% .24 277.023);--color-purple-100:oklch(94.6% .033 307.174);--color-purple-600:oklch(55.8% .288 302.321);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-900:oklch(21% .034 264.665);--color-white:#fff;--spacing:.25rem;--container-md:28rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--container-6xl:72rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1/.75);--text-sm:.875rem;--text-sm--line-height:calc(1.25/.875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75/1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75/1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25/1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5/2.25);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--radius-md:.375rem;--radius-lg:.5rem;--animate-spin:spin 1s linear infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.static{position:static}.top-0{top:calc(var(--spacing)*0)}.z-40{z-index:40}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.mx-auto{margin-inline:auto}.mt-1{margin-top:calc(var(--spacing)*1)}.mt-2{margin-top:calc(var(--spacing)*2)}.mt-4{margin-top:calc(var(--spacing)*4)}.mt-8{margin-top:calc(var(--spacing)*8)}.mt-auto{margin-top:auto}.mr-1{margin-right:calc(var(--spacing)*1)}.mr-2{margin-right:calc(var(--spacing)*2)}.mr-3{margin-right:calc(var(--spacing)*3)}.mb-2{margin-bottom:calc(var(--spacing)*2)}.mb-4{margin-bottom:calc(var(--spacing)*4)}.mb-6{margin-bottom:calc(var(--spacing)*6)}.mb-8{margin-bottom:calc(var(--spacing)*8)}.ml-1{margin-left:calc(var(--spacing)*1)}.ml-2{margin-left:calc(var(--spacing)*2)}.ml-4{margin-left:calc(var(--spacing)*4)}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.table{display:table}.h-3{height:calc(var(--spacing)*3)}.h-4{height:calc(var(--spacing)*4)}.h-5{height:calc(var(--spacing)*5)}.h-6{height:calc(var(--spacing)*6)}.h-8{height:calc(var(--spacing)*8)}.h-12{height:calc(var(--spacing)*12)}.h-16{height:calc(var(--spacing)*16)}.min-h-\[60vh\]{min-height:60vh}.min-h-screen{min-height:100vh}.w-3{width:calc(var(--spacing)*3)}.w-4{width:calc(var(--spacing)*4)}.w-5{width:calc(var(--spacing)*5)}.w-6{width:calc(var(--spacing)*6)}.w-8{width:calc(var(--spacing)*8)}.w-12{width:calc(var(--spacing)*12)}.w-16{width:calc(var(--spacing)*16)}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-7xl{max-width:var(--container-7xl)}.max-w-md{max-width:var(--container-md)}.flex-1{flex:1}.flex-shrink-0{flex-shrink:0}.animate-spin{animation:var(--animate-spin)}.cursor-pointer{cursor:pointer}.resize{resize:both}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.items-center{align-items:center}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-1{gap:calc(var(--spacing)*1)}.gap-3{gap:calc(var(--spacing)*3)}.gap-4{gap:calc(var(--spacing)*4)}.gap-6{gap:calc(var(--spacing)*6)}.gap-8{gap:calc(var(--spacing)*8)}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*2)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*2)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*4)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*4)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*6)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*6)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-8>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*8)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*8)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-x-4>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing)*4)*var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing)*4)*calc(1 - var(--tw-space-x-reverse)))}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-gray-200{border-color:var(--color-gray-200)}.border-gray-300{border-color:var(--color-gray-300)}.border-green-200{border-color:var(--color-green-200)}.border-red-200{border-color:var(--color-red-200)}.border-transparent{border-color:#0000}.border-white{border-color:var(--color-white)}.bg-blue-100{background-color:var(--color-blue-100)}.bg-blue-600{background-color:var(--color-blue-600)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-green-50{background-color:var(--color-green-50)}.bg-green-100{background-color:var(--color-green-100)}.bg-green-600{background-color:var(--color-green-600)}.bg-orange-100{background-color:var(--color-orange-100)}.bg-purple-100{background-color:var(--color-purple-100)}.bg-red-50{background-color:var(--color-red-50)}.bg-white{background-color:var(--color-white)}.bg-gradient-to-r{--tw-gradient-position:to right in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.from-blue-600{--tw-gradient-from:var(--color-blue-600);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.to-indigo-700{--tw-gradient-to:var(--color-indigo-700);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.p-3{padding:calc(var(--spacing)*3)}.p-4{padding:calc(var(--spacing)*4)}.p-6{padding:calc(var(--spacing)*6)}.p-8{padding:calc(var(--spacing)*8)}.px-3{padding-inline:calc(var(--spacing)*3)}.px-4{padding-inline:calc(var(--spacing)*4)}.px-6{padding-inline:calc(var(--spacing)*6)}.py-2{padding-block:calc(var(--spacing)*2)}.py-3{padding-block:calc(var(--spacing)*3)}.py-6{padding-block:calc(var(--spacing)*6)}.py-8{padding-block:calc(var(--spacing)*8)}.text-center{text-align:center}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.text-blue-100{color:var(--color-blue-100)}.text-blue-600{color:var(--color-blue-600)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-900{color:var(--color-gray-900)}.text-green-600{color:var(--color-green-600)}.text-green-700{color:var(--color-green-700)}.text-orange-600{color:var(--color-orange-600)}.text-purple-600{color:var(--color-purple-600)}.text-red-700{color:var(--color-red-700)}.text-white{color:var(--color-white)}.ordinal{--tw-ordinal:ordinal;font-variant-numeric:var(--tw-ordinal,)var(--tw-slashed-zero,)var(--tw-numeric-figure,)var(--tw-numeric-spacing,)var(--tw-numeric-fraction,)}.placeholder-gray-400::placeholder{color:var(--color-gray-400)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a),0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a),0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}@media (hover:hover){.hover\:bg-blue-700:hover{background-color:var(--color-blue-700)}.hover\:bg-gray-100:hover{background-color:var(--color-gray-100)}.hover\:bg-green-700:hover{background-color:var(--color-green-700)}.hover\:bg-white:hover{background-color:var(--color-white)}.hover\:text-blue-500:hover{color:var(--color-blue-500)}.hover\:text-blue-600:hover{color:var(--color-blue-600)}.hover\:text-gray-900:hover{color:var(--color-gray-900)}.hover\:text-red-600:hover{color:var(--color-red-600)}.hover\:opacity-80:hover{opacity:.8}}.focus\:border-blue-500:focus{border-color:var(--color-blue-500)}.focus\:border-green-500:focus{border-color:var(--color-green-500)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-blue-500:focus{--tw-ring-color:var(--color-blue-500)}.focus\:ring-green-500:focus{--tw-ring-color:var(--color-green-500)}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}@media (min-width:40rem){.sm\:flex-row{flex-direction:row}.sm\:px-6{padding-inline:calc(var(--spacing)*6)}}@media (min-width:48rem){.md\:mt-0{margin-top:calc(var(--spacing)*0)}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:flex-row{flex-direction:row}:where(.md\:space-y-0>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*0)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*0)*calc(1 - var(--tw-space-y-reverse)))}:where(.md\:space-x-6>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing)*6)*var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing)*6)*calc(1 - var(--tw-space-x-reverse)))}}@media (min-width:64rem){.lg\:ml-0{margin-left:calc(var(--spacing)*0)}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:px-8{padding-inline:calc(var(--spacing)*8)}}}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-space-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-gradient-position{syntax:"*";inherits:false}@property --tw-gradient-from{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-via{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-to{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-stops{syntax:"*";inherits:false}@property --tw-gradient-via-stops{syntax:"*";inherits:false}@property --tw-gradient-from-position{syntax:"";inherits:false;initial-value:0%}@property --tw-gradient-via-position{syntax:"";inherits:false;initial-value:50%}@property --tw-gradient-to-position{syntax:"";inherits:false;initial-value:100%}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@keyframes spin{to{transform:rotate(360deg)}} \ No newline at end of file diff --git a/src/email_client.rs b/src/email_client.rs index 010034b..790e225 100644 --- a/src/email_client.rs +++ b/src/email_client.rs @@ -137,7 +137,7 @@ mod tests { Mock::given(header_exists("Authorization")) .and(header("Content-Type", "application/json")) .and(header("X-Requested-With", "XMLHttpRequest")) - .and(path("v1/email")) + .and(path("email")) .and(method("POST")) .and(SendEmailBodyMatcher) .respond_with(ResponseTemplate::new(200)) diff --git a/src/lib.rs b/src/lib.rs index ef0b064..3a942e2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,3 +8,4 @@ pub mod routes; pub mod session_state; pub mod startup; pub mod telemetry; +pub mod templates; diff --git a/src/routes/admin.rs b/src/routes/admin.rs index da9ad45..5cf8cbd 100644 --- a/src/routes/admin.rs +++ b/src/routes/admin.rs @@ -2,12 +2,12 @@ pub mod change_password; pub mod dashboard; pub mod newsletters; -use crate::{routes::error_chain_fmt, session_state::TypedSession}; +use crate::{routes::error_chain_fmt, session_state::TypedSession, templates::ErrorTemplate}; +use askama::Template; use axum::{ Json, - response::{IntoResponse, Redirect, Response}, + response::{Html, IntoResponse, Redirect, Response}, }; -use axum_messages::Messages; pub use change_password::*; pub use dashboard::*; pub use newsletters::*; @@ -20,7 +20,7 @@ pub enum AdminError { #[error("Trying to access admin dashboard without authentication.")] NotAuthenticated, #[error("Updating password failed.")] - ChangePassword, + ChangePassword(String), #[error("Could not publish newsletter.")] Publish(#[source] anyhow::Error), #[error("The idempotency key was invalid.")] @@ -51,8 +51,18 @@ impl IntoResponse for AdminError { ) .into_response(), AdminError::NotAuthenticated => Redirect::to("/login").into_response(), - AdminError::ChangePassword => Redirect::to("/admin/password").into_response(), - AdminError::Publish(_) => Redirect::to("/admin/newsletters").into_response(), + AdminError::ChangePassword(e) => { + let template = ErrorTemplate { + error_message: e.to_owned(), + }; + Html(template.render().unwrap()).into_response() + } + AdminError::Publish(e) => { + let template = ErrorTemplate { + error_message: e.to_string(), + }; + Html(template.render().unwrap()).into_response() + } AdminError::Idempotency(e) => { (StatusCode::BAD_REQUEST, Json(ErrorResponse { message: e })).into_response() } @@ -60,9 +70,8 @@ impl IntoResponse for AdminError { } } -#[tracing::instrument(name = "Logging out", skip(messages, session))] -pub async fn logout(messages: Messages, session: TypedSession) -> Result { +#[tracing::instrument(name = "Logging out", skip(session))] +pub async fn logout(session: TypedSession) -> Result { session.clear().await; - messages.success("You have successfully logged out."); Ok(Redirect::to("/login").into_response()) } diff --git a/src/routes/admin/change_password.rs b/src/routes/admin/change_password.rs index d15b53a..6e73354 100644 --- a/src/routes/admin/change_password.rs +++ b/src/routes/admin/change_password.rs @@ -2,13 +2,14 @@ use crate::{ authentication::{self, AuthenticatedUser, Credentials, validate_credentials}, routes::AdminError, startup::AppState, + templates::SuccessTemplate, }; +use askama::Template; use axum::{ Extension, Form, extract::State, - response::{IntoResponse, Redirect, Response}, + response::{Html, IntoResponse, Response}, }; -use axum_messages::Messages; use secrecy::{ExposeSecret, SecretString}; #[derive(serde::Deserialize)] @@ -23,7 +24,6 @@ pub async fn change_password( State(AppState { connection_pool, .. }): State, - messages: Messages, Form(form): Form, ) -> Result { let credentials = Credentials { @@ -31,23 +31,26 @@ pub async fn change_password( password: form.current_password, }; if form.new_password.expose_secret() != form.new_password_check.expose_secret() { - messages.error("You entered two different passwords - the field values must match."); - Err(AdminError::ChangePassword) + Err(AdminError::ChangePassword( + "You entered two different passwords - the field values must match.".to_string(), + )) } else if validate_credentials(credentials, &connection_pool) .await .is_err() { - messages.error("The current password is incorrect."); - Err(AdminError::ChangePassword) + Err(AdminError::ChangePassword( + "The current password is incorrect.".to_string(), + )) } else if let Err(e) = verify_password(form.new_password.expose_secret()) { - messages.error(e); - Err(AdminError::ChangePassword) + Err(AdminError::ChangePassword(e)) } else { authentication::change_password(user_id, form.new_password, &connection_pool) .await - .map_err(|_| AdminError::ChangePassword)?; - messages.success("Your password has been changed."); - Ok(Redirect::to("/admin/password").into_response()) + .map_err(|e| AdminError::ChangePassword(e.to_string()))?; + let template = SuccessTemplate { + success_message: "Your password has been changed.".to_string(), + }; + Ok(Html(template.render().unwrap()).into_response()) } } diff --git a/src/routes/admin/newsletters.rs b/src/routes/admin/newsletters.rs index 4bac5cf..01a9844 100644 --- a/src/routes/admin/newsletters.rs +++ b/src/routes/admin/newsletters.rs @@ -3,14 +3,15 @@ use crate::{ idempotency::{IdempotencyKey, save_response, try_processing}, routes::AdminError, startup::AppState, + templates::SuccessTemplate, }; use anyhow::Context; +use askama::Template; use axum::{ Extension, Form, extract::State, - response::{IntoResponse, Redirect, Response}, + response::{Html, IntoResponse, Response}, }; -use axum_messages::Messages; use sqlx::{Executor, Postgres, Transaction}; use uuid::Uuid; @@ -67,20 +68,15 @@ async fn enqueue_delivery_tasks( Ok(()) } -#[tracing::instrument( - name = "Publishing a newsletter", - skip(connection_pool, form, messages) -)] +#[tracing::instrument(name = "Publishing a newsletter", skip(connection_pool, form))] pub async fn publish_newsletter( State(AppState { connection_pool, .. }): State, Extension(AuthenticatedUser { user_id, .. }): Extension, - messages: Messages, Form(form): Form, ) -> Result { if let Err(e) = validate_form(&form) { - messages.error(e); return Err(AdminError::Publish(anyhow::anyhow!(e))); } @@ -89,17 +85,9 @@ pub async fn publish_newsletter( .try_into() .map_err(AdminError::Idempotency)?; - let success_message = || { - messages.success(format!( - "The newsletter issue '{}' has been published!", - form.title - )) - }; - let mut transaction = match try_processing(&connection_pool, &idempotency_key, user_id).await? { crate::idempotency::NextAction::StartProcessing(t) => t, crate::idempotency::NextAction::ReturnSavedResponse(response) => { - success_message(); return Ok(response); } }; @@ -112,8 +100,12 @@ pub async fn publish_newsletter( .await .context("Failed to enqueue delivery tasks")?; - let response = Redirect::to("/admin/newsletters").into_response(); - success_message(); + let success_message = format!( + r#"The newsletter issue "{}" has been published!"#, + form.title + ); + let template = SuccessTemplate { success_message }; + let response = Html(template.render().unwrap()).into_response(); save_response(transaction, &idempotency_key, user_id, response) .await .map_err(AdminError::UnexpectedError) diff --git a/src/routes/home.rs b/src/routes/home.rs index 5f36524..8782a41 100644 --- a/src/routes/home.rs +++ b/src/routes/home.rs @@ -1,19 +1,10 @@ use askama::Template; use axum::response::Html; -use axum_messages::Messages; #[derive(Template)] #[template(path = "../templates/home.html")] -struct HomeTemplate { - message: String, -} +struct HomeTemplate; -pub async fn home(messages: Messages) -> Html { - let template = HomeTemplate { - message: messages - .last() - .map(|msg| msg.to_string()) - .unwrap_or_default(), - }; - Html(template.render().unwrap()) +pub async fn home() -> Html { + Html(HomeTemplate.render().unwrap()) } diff --git a/src/routes/login.rs b/src/routes/login.rs index d004f7d..9ab268d 100644 --- a/src/routes/login.rs +++ b/src/routes/login.rs @@ -3,6 +3,7 @@ use crate::{ routes::error_chain_fmt, session_state::TypedSession, startup::AppState, + templates::ErrorTemplate, }; use askama::Template; use axum::{ @@ -10,7 +11,6 @@ use axum::{ extract::State, response::{Html, IntoResponse, Redirect, Response}, }; -use axum_messages::Messages; use reqwest::StatusCode; use secrecy::SecretString; @@ -45,16 +45,19 @@ impl IntoResponse for LoginError { }), ) .into_response(), - LoginError::AuthError(_) => Redirect::to("/login").into_response(), + LoginError::AuthError(e) => { + let template = ErrorTemplate { + error_message: e.to_string(), + }; + Html(template.render().unwrap()).into_response() + } } } } #[derive(Template)] #[template(path = "../templates/login.html")] -struct LoginTemplate { - error: String, -} +struct LoginTemplate; #[derive(serde::Deserialize)] pub struct LoginFormData { @@ -62,19 +65,12 @@ pub struct LoginFormData { password: SecretString, } -pub async fn get_login(messages: Messages) -> Html { - let template = LoginTemplate { - error: messages - .last() - .map(|msg| msg.to_string()) - .unwrap_or_default(), - }; - Html(template.render().unwrap()) +pub async fn get_login() -> Html { + Html(LoginTemplate.render().unwrap()) } pub async fn post_login( session: TypedSession, - messages: Messages, State(AppState { connection_pool, .. }): State, @@ -89,11 +85,7 @@ pub async fn post_login( Err(e) => { let e = match e { AuthError::UnexpectedError(_) => LoginError::UnexpectedError(e.into()), - AuthError::InvalidCredentials(_) => { - let e = LoginError::AuthError(e.into()); - messages.error(e.to_string()); - e - } + AuthError::InvalidCredentials(_) => LoginError::AuthError(e.into()), AuthError::NotAuthenticated => unreachable!(), }; Err(e) diff --git a/src/routes/subscriptions.rs b/src/routes/subscriptions.rs index 2711cfc..6ba03df 100644 --- a/src/routes/subscriptions.rs +++ b/src/routes/subscriptions.rs @@ -2,15 +2,16 @@ use crate::{ domain::{NewSubscriber, SubscriberEmail}, email_client::EmailClient, startup::AppState, + templates::{ErrorTemplate, SuccessTemplate}, }; use anyhow::Context; +use askama::Template; use axum::{ Form, Json, extract::State, http::StatusCode, - response::{IntoResponse, Redirect, Response}, + response::{Html, IntoResponse, Response}, }; -use axum_messages::Messages; use chrono::Utc; use rand::{Rng, distr::Alphanumeric}; use serde::Deserialize; @@ -72,20 +73,22 @@ impl IntoResponse for SubscribeError { }), ) .into_response(), - SubscribeError::ValidationError(_) => Redirect::to("/").into_response(), + SubscribeError::ValidationError(e) => { + let template = ErrorTemplate { error_message: e }; + Html(template.render().unwrap()).into_response() + } } } } #[tracing::instrument( name = "Adding a new subscriber", - skip(messages, connection_pool, email_client, base_url, form), + skip(connection_pool, email_client, base_url, form), fields( subscriber_email = %form.email, ) )] pub async fn subscribe( - messages: Messages, State(AppState { connection_pool, email_client, @@ -97,7 +100,6 @@ pub async fn subscribe( let new_subscriber = match form.try_into() { Ok(new_sub) => new_sub, Err(e) => { - messages.error(&e); return Err(SubscribeError::ValidationError(e)); } }; @@ -124,8 +126,10 @@ pub async fn subscribe( .commit() .await .context("Failed to commit the database transaction to store a new subscriber.")?; - messages.success("A confirmation email has been sent."); - Ok(Redirect::to("/").into_response()) + let template = SuccessTemplate { + success_message: "A confirmation email has been sent.".to_string(), + }; + Ok(Html(template.render().unwrap()).into_response()) } #[tracing::instrument( diff --git a/src/startup.rs b/src/startup.rs index 37e4d38..06ad906 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -8,7 +8,6 @@ use axum::{ middleware, routing::{get, post}, }; -use axum_messages::MessagesManagerLayer; use axum_server::tls_rustls::RustlsConfig; use secrecy::ExposeSecret; use sqlx::{PgPool, postgres::PgPoolOptions}; @@ -147,7 +146,6 @@ pub fn app( ) }), ) - .layer(MessagesManagerLayer) .layer(SessionManagerLayer::new(redis_store).with_secure(false)) .with_state(app_state) } diff --git a/src/templates.rs b/src/templates.rs new file mode 100644 index 0000000..3112ff8 --- /dev/null +++ b/src/templates.rs @@ -0,0 +1,13 @@ +use askama::Template; + +#[derive(Template)] +#[template(path = "../templates/success.html")] +pub struct SuccessTemplate { + pub success_message: String, +} + +#[derive(Template)] +#[template(path = "../templates/error.html")] +pub struct ErrorTemplate { + pub error_message: String, +} diff --git a/templates/dashboard.html b/templates/dashboard.html index 97b2190..b7c5180 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% block title %}Admin Dashboard - zero2prod{% endblock %} +{% block title %}Dashboard - zero2prod{% endblock %} {% block content %}
@@ -7,6 +7,15 @@

Connected as {{ username }}

+
+ +
@@ -99,7 +108,10 @@

Create and send a newsletter issue.

-
+
@@ -107,7 +119,6 @@ id="title" name="title" required - placeholder="Enter newsletter title..." class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
@@ -116,7 +127,6 @@ name="html" rows="6" required - placeholder="Enter HTML content..." class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm">
@@ -125,7 +135,6 @@ name="text" rows="6" required - placeholder="Enter plain text content..." class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
+
@@ -155,7 +165,10 @@

Set a new password for your account.

-
+
@@ -193,60 +206,10 @@ Update password +
-
-
-

Recent activity

-
-
-
-
-
- - - -
-
-

Newsletter "Weekly Update #23" sent

-

2 hours ago • 2,143 recipients

-
-
-
-
- - - -
-
-

5 new subscribers confirmed

-

1 day ago

-
-
-
-
- - - -
-
-

Password changed successfully

-

3 days ago

-
-
-
-
-
{% endblock %} diff --git a/templates/error.html b/templates/error.html new file mode 100644 index 0000000..fcbcc87 --- /dev/null +++ b/templates/error.html @@ -0,0 +1,9 @@ +
+
+ + + + + {{ error_message }} +
+
diff --git a/templates/home.html b/templates/home.html index 68865e5..6681cb3 100644 --- a/templates/home.html +++ b/templates/home.html @@ -74,11 +74,14 @@

Stay updated

Subscribe to our newsletter to get the latest updates.

-
+
+
-
{{ message }}
diff --git a/templates/login.html b/templates/login.html index 65ab4f5..6a107d4 100644 --- a/templates/login.html +++ b/templates/login.html @@ -8,7 +8,10 @@

Sign in to access the admin dashboard.

-
+
+
-
{{ error }}