Auto-indexing service and GraphQL API for AT Protocol Records
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

move to lustre SPA instead of server only

+4399 -2881
+1 -1
.github/workflows/docker-publish.yml
··· 55 55 labels: ${{ steps.meta.outputs.labels }} 56 56 cache-from: type=gha 57 57 cache-to: type=gha,mode=max 58 - platforms: linux/amd64,linux/arm64 58 + platforms: linux/amd64
+3
.gitignore
··· 11 11 *.env 12 12 13 13 tmp 14 + 15 + .lustre 16 + build/
+5
Dockerfile
··· 38 38 # Apply patches to dependencies 39 39 RUN cd /build && patch -p1 < patches/mist-websocket-protocol.patch 40 40 41 + # Compile the client code and output to server's static directory 42 + RUN cd /build/client \ 43 + && gleam add --dev lustre_dev_tools \ 44 + && gleam run -m lustre/dev build client --minify --outdir=/build/server/priv/static 45 + 41 46 # Compile the server code 42 47 RUN cd /build/server \ 43 48 && gleam export erlang-shipment
+24
client/README.md
··· 1 + # cache_example 2 + 3 + [![Package Version](https://img.shields.io/hexpm/v/cache_example)](https://hex.pm/packages/cache_example) 4 + [![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/cache_example/) 5 + 6 + ```sh 7 + gleam add cache_example@1 8 + ``` 9 + ```gleam 10 + import cache_example 11 + 12 + pub fn main() -> Nil { 13 + // TODO: An example of the project in use 14 + } 15 + ``` 16 + 17 + Further documentation can be found at <https://hexdocs.pm/cache_example>. 18 + 19 + ## Development 20 + 21 + ```sh 22 + gleam run # Run the project 23 + gleam test # Run the tests 24 + ```
+2
client/assets/styles.css
··· 1 + /*! tailwindcss v4.1.17 | MIT License | https://tailwindcss.com */ 2 + @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-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking: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;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@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-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-800:oklch(44.4% .177 26.899);--color-red-900:oklch(39.6% .141 25.723);--color-red-950:oklch(25.8% .092 26.042);--color-yellow-300:oklch(90.5% .182 98.111);--color-yellow-400:oklch(85.2% .199 91.936);--color-yellow-500:oklch(79.5% .184 86.047);--color-yellow-800:oklch(47.6% .114 61.907);--color-yellow-900:oklch(42.1% .095 57.708);--color-green-300:oklch(87.1% .15 154.449);--color-green-400:oklch(79.2% .209 151.711);--color-green-500:oklch(72.3% .219 149.579);--color-green-800:oklch(44.8% .119 151.328);--color-green-900:oklch(39.3% .095 152.535);--color-blue-300:oklch(80.9% .105 251.813);--color-blue-400:oklch(70.7% .165 254.624);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-800:oklch(42.4% .199 265.638);--color-blue-900:oklch(37.9% .146 265.522);--color-purple-400:oklch(71.4% .203 305.504);--color-zinc-100:oklch(96.7% .001 286.375);--color-zinc-200:oklch(92% .004 286.32);--color-zinc-300:oklch(87.1% .006 286.286);--color-zinc-400:oklch(70.5% .015 286.067);--color-zinc-500:oklch(55.2% .016 285.938);--color-zinc-600:oklch(44.2% .017 285.786);--color-zinc-700:oklch(37% .013 285.805);--color-zinc-800:oklch(27.4% .006 286.033);--color-zinc-900:oklch(21% .006 285.885);--color-zinc-950:oklch(14.1% .005 285.823);--color-black:#000;--spacing:.25rem;--container-2xl:42rem;--container-4xl:56rem;--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-4xl:2.25rem;--text-4xl--line-height:calc(2.5/2.25);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-wider:.05em;--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{.absolute{position:absolute}.static{position:static}.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-6{margin-top:calc(var(--spacing)*6)}.mb-1{margin-bottom:calc(var(--spacing)*1)}.mb-2{margin-bottom:calc(var(--spacing)*2)}.mb-3{margin-bottom:calc(var(--spacing)*3)}.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}.inline{display:inline}.table{display:table}.h-2{height:calc(var(--spacing)*2)}.h-10{height:calc(var(--spacing)*10)}.h-full{height:100%}.max-h-80{max-height:calc(var(--spacing)*80)}.max-h-96{max-height:calc(var(--spacing)*96)}.min-h-screen{min-height:100vh}.w-2{width:calc(var(--spacing)*2)}.w-4{width:calc(var(--spacing)*4)}.w-10{width:calc(var(--spacing)*10)}.w-12{width:calc(var(--spacing)*12)}.w-20{width:calc(var(--spacing)*20)}.w-40{width:calc(var(--spacing)*40)}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-4xl{max-width:var(--container-4xl)}.shrink-0{flex-shrink:0}.transform{transform:var(--tw-rotate-x,)var(--tw-rotate-y,)var(--tw-rotate-z,)var(--tw-skew-x,)var(--tw-skew-y,)}.cursor-pointer{cursor:pointer}.list-disc{list-style-type:disc}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.gap-2{gap:calc(var(--spacing)*2)}.gap-3{gap:calc(var(--spacing)*3)}.gap-4{gap:calc(var(--spacing)*4)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*1)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*1)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*3)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*3)*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)))}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.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-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-blue-800{border-color:var(--color-blue-800)}.border-green-800{border-color:var(--color-green-800)}.border-red-800{border-color:var(--color-red-800)}.border-red-900{border-color:var(--color-red-900)}.border-yellow-800{border-color:var(--color-yellow-800)}.border-zinc-700{border-color:var(--color-zinc-700)}.border-zinc-700\/50{border-color:#3f3f4680}@supports (color:color-mix(in lab, red, red)){.border-zinc-700\/50{border-color:color-mix(in oklab,var(--color-zinc-700)50%,transparent)}}.border-zinc-800{border-color:var(--color-zinc-800)}.bg-black\/40{background-color:#0006}@supports (color:color-mix(in lab, red, red)){.bg-black\/40{background-color:color-mix(in oklab,var(--color-black)40%,transparent)}}.bg-blue-900\/30{background-color:#1c398e4d}@supports (color:color-mix(in lab, red, red)){.bg-blue-900\/30{background-color:color-mix(in oklab,var(--color-blue-900)30%,transparent)}}.bg-green-500{background-color:var(--color-green-500)}.bg-green-900\/30{background-color:#0d542b4d}@supports (color:color-mix(in lab, red, red)){.bg-green-900\/30{background-color:color-mix(in oklab,var(--color-green-900)30%,transparent)}}.bg-red-900\/30{background-color:#82181a4d}@supports (color:color-mix(in lab, red, red)){.bg-red-900\/30{background-color:color-mix(in oklab,var(--color-red-900)30%,transparent)}}.bg-red-950{background-color:var(--color-red-950)}.bg-yellow-900\/30{background-color:#733e0a4d}@supports (color:color-mix(in lab, red, red)){.bg-yellow-900\/30{background-color:color-mix(in oklab,var(--color-yellow-900)30%,transparent)}}.bg-zinc-500{background-color:var(--color-zinc-500)}.bg-zinc-700{background-color:var(--color-zinc-700)}.bg-zinc-800{background-color:var(--color-zinc-800)}.bg-zinc-800\/50{background-color:#27272a80}@supports (color:color-mix(in lab, red, red)){.bg-zinc-800\/50{background-color:color-mix(in oklab,var(--color-zinc-800)50%,transparent)}}.bg-zinc-900{background-color:var(--color-zinc-900)}.bg-zinc-900\/50{background-color:#18181b80}@supports (color:color-mix(in lab, red, red)){.bg-zinc-900\/50{background-color:color-mix(in oklab,var(--color-zinc-900)50%,transparent)}}.bg-zinc-950{background-color:var(--color-zinc-950)}.p-2{padding:calc(var(--spacing)*2)}.p-3{padding:calc(var(--spacing)*3)}.p-4{padding:calc(var(--spacing)*4)}.p-6{padding:calc(var(--spacing)*6)}.px-2{padding-inline:calc(var(--spacing)*2)}.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-1{padding-block:calc(var(--spacing)*1)}.py-2{padding-block:calc(var(--spacing)*2)}.py-3{padding-block:calc(var(--spacing)*3)}.py-4{padding-block:calc(var(--spacing)*4)}.py-8{padding-block:calc(var(--spacing)*8)}.py-12{padding-block:calc(var(--spacing)*12)}.pb-4{padding-bottom:calc(var(--spacing)*4)}.text-center{text-align:center}.text-right{text-align:right}.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-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))}.text-\[10px\]{font-size:10px}.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)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.break-all{word-break:break-all}.whitespace-pre-wrap{white-space:pre-wrap}.text-blue-300{color:var(--color-blue-300)}.text-blue-400{color:var(--color-blue-400)}.text-blue-500{color:var(--color-blue-500)}.text-green-300{color:var(--color-green-300)}.text-green-400{color:var(--color-green-400)}.text-green-500{color:var(--color-green-500)}.text-purple-400{color:var(--color-purple-400)}.text-red-300{color:var(--color-red-300)}.text-red-400{color:var(--color-red-400)}.text-red-500{color:var(--color-red-500)}.text-yellow-300{color:var(--color-yellow-300)}.text-yellow-400{color:var(--color-yellow-400)}.text-yellow-500{color:var(--color-yellow-500)}.text-zinc-100{color:var(--color-zinc-100)}.text-zinc-200{color:var(--color-zinc-200)}.text-zinc-300{color:var(--color-zinc-300)}.text-zinc-400{color:var(--color-zinc-400)}.text-zinc-500{color:var(--color-zinc-500)}.text-zinc-600{color:var(--color-zinc-600)}.lowercase{text-transform:lowercase}.uppercase{text-transform:uppercase}.underline{text-decoration-line:underline}.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)}.filter{filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-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))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.select-none{-webkit-user-select:none;user-select:none}@media (hover:hover){.group-hover\:fill-zinc-700:is(:where(.group):hover *){fill:var(--color-zinc-700)}.group-hover\:text-zinc-400:is(:where(.group):hover *){color:var(--color-zinc-400)}.group-hover\:opacity-80:is(:where(.group):hover *){opacity:.8}.hover\:border-zinc-600:hover{border-color:var(--color-zinc-600)}.hover\:bg-red-900\/30:hover{background-color:#82181a4d}@supports (color:color-mix(in lab, red, red)){.hover\:bg-red-900\/30:hover{background-color:color-mix(in oklab,var(--color-red-900)30%,transparent)}}.hover\:bg-zinc-600:hover{background-color:var(--color-zinc-600)}.hover\:bg-zinc-700:hover{background-color:var(--color-zinc-700)}.hover\:bg-zinc-700\/50:hover{background-color:#3f3f4680}@supports (color:color-mix(in lab, red, red)){.hover\:bg-zinc-700\/50:hover{background-color:color-mix(in oklab,var(--color-zinc-700)50%,transparent)}}.hover\:bg-zinc-900\/30:hover{background-color:#18181b4d}@supports (color:color-mix(in lab, red, red)){.hover\:bg-zinc-900\/30:hover{background-color:color-mix(in oklab,var(--color-zinc-900)30%,transparent)}}.hover\:text-zinc-100:hover{color:var(--color-zinc-100)}.hover\:text-zinc-200:hover{color:var(--color-zinc-200)}.hover\:text-zinc-300:hover{color:var(--color-zinc-300)}.hover\:no-underline:hover{text-decoration-line:none}.hover\:opacity-80:hover{opacity:.8}}.focus\:border-zinc-600:focus{border-color:var(--color-zinc-600)}.focus\:border-zinc-700:focus{border-color:var(--color-zinc-700)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}@media (hover:hover){.disabled\:hover\:bg-zinc-800:disabled:hover{background-color:var(--color-zinc-800)}}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{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:"<percentage>";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:"<percentage>";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:"<length>";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}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}
+28
client/gleam.toml
··· 1 + name = "quickslice_client" 2 + version = "1.0.0" 3 + target = "javascript" 4 + 5 + [tools.lustre.build] 6 + minify = true 7 + # Build the application into our server's `priv/static` directory so it can be 8 + # deployed together with the backend. 9 + outdir = "../server/priv/static" 10 + 11 + [tools.lustre.html] 12 + title = "quickslice" 13 + stylesheets = [ 14 + { href = "/styles.css" } 15 + ] 16 + 17 + [dependencies] 18 + gleam_stdlib = ">= 0.44.0 and < 2.0.0" 19 + lustre = ">= 5.0.0 and < 6.0.0" 20 + gleam_json = ">= 3.0.0 and < 4.0.0" 21 + squall = { path = "../../squall" } 22 + squall_cache = { path = "../../squall_cache" } 23 + modem = ">= 2.0.0 and < 3.0.0" 24 + gleam_http = ">= 4.3.0 and < 5.0.0" 25 + 26 + [dev-dependencies] 27 + gleeunit = ">= 1.0.0 and < 2.0.0" 28 + lustre_dev_tools = ">= 2.2.2 and < 3.0.0"
+61
client/manifest.toml
··· 1 + # This file was generated by Gleam 2 + # You typically do not need to edit this file 3 + 4 + packages = [ 5 + { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" }, 6 + { name = "booklet", version = "1.1.0", build_tools = ["gleam"], requirements = [], otp_app = "booklet", source = "hex", outer_checksum = "08E0FDB78DC4D8A5D3C80295B021505C7D2A2E7B6C6D5EAB7286C36F4A53C851" }, 7 + { name = "directories", version = "1.2.0", build_tools = ["gleam"], requirements = ["envoy", "gleam_stdlib", "platform", "simplifile"], otp_app = "directories", source = "hex", outer_checksum = "D13090CFCDF6759B87217E8DDD73A75903A700148A82C1D33799F333E249BF9E" }, 8 + { name = "envoy", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "95FD059345AA982E89A0B6E2A3BF1CF43E17A7048DCD85B5B65D3B9E4E39D359" }, 9 + { name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" }, 10 + { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, 11 + { name = "glam", version = "2.0.3", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "glam", source = "hex", outer_checksum = "237C2CE218A2A0A5D46D625F8EF5B78F964BC91018B78D692B17E1AB84295229" }, 12 + { name = "gleam_community_ansi", version = "1.4.3", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_regexp", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "8A62AE9CC6EA65BEA630D95016D6C07E4F9973565FA3D0DE68DC4200D8E0DD27" }, 13 + { name = "gleam_community_colour", version = "2.0.2", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "E34DD2C896AC3792151EDA939DA435FF3B69922F33415ED3C4406C932FBE9634" }, 14 + { name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" }, 15 + { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, 16 + { name = "gleam_fetch", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_javascript", "gleam_stdlib"], otp_app = "gleam_fetch", source = "hex", outer_checksum = "2CBF9F2E1C71AEBBFB13A9D5720CD8DB4263EB02FE60C5A7A1C6E17B0151C20C" }, 17 + { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, 18 + { name = "gleam_httpc", version = "5.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "C545172618D07811494E97AAA4A0FB34DA6F6D0061FDC8041C2F8E3BE2B2E48F" }, 19 + { name = "gleam_javascript", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_javascript", source = "hex", outer_checksum = "EF6C77A506F026C6FB37941889477CD5E4234FCD4337FF0E9384E297CB8F97EB" }, 20 + { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, 21 + { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, 22 + { name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" }, 23 + { name = "gleam_stdlib", version = "0.65.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "7C69C71D8C493AE11A5184828A77110EB05A7786EBF8B25B36A72F879C3EE107" }, 24 + { name = "gleam_time", version = "1.5.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "D560E672C7279C89908981E068DF07FD16D0C859DCA266F908B18F04DF0EB8E6" }, 25 + { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, 26 + { name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" }, 27 + { name = "glint", version = "1.2.1", build_tools = ["gleam"], requirements = ["gleam_community_ansi", "gleam_community_colour", "gleam_stdlib", "snag"], otp_app = "glint", source = "hex", outer_checksum = "2214C7CEFDE457CEE62140C3D4899B964E05236DA74E4243DFADF4AF29C382BB" }, 28 + { name = "glisten", version = "8.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "534BB27C71FB9E506345A767C0D76B17A9E9199934340C975DC003C710E3692D" }, 29 + { name = "gramps", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "8B7195978FBFD30B43DF791A8A272041B81E45D245314D7A41FC57237AA882A0" }, 30 + { name = "group_registry", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib"], otp_app = "group_registry", source = "hex", outer_checksum = "BC798A53D6F2406DB94E27CB45C57052CB56B32ACF7CC16EA20F6BAEC7E36B90" }, 31 + { name = "houdini", version = "1.2.0", build_tools = ["gleam"], requirements = [], otp_app = "houdini", source = "hex", outer_checksum = "5DB1053F1AF828049C2B206D4403C18970ABEF5C18671CA3C2D2ED0DD64F6385" }, 32 + { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, 33 + { name = "justin", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "justin", source = "hex", outer_checksum = "7FA0C6DB78640C6DC5FBFD59BF3456009F3F8B485BF6825E97E1EB44E9A1E2CD" }, 34 + { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, 35 + { name = "lustre", version = "5.4.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib", "houdini"], otp_app = "lustre", source = "hex", outer_checksum = "40E097BABCE65FB7C460C073078611F7F5802EB07E1A9BFB5C229F71B60F8E50" }, 36 + { name = "lustre_dev_tools", version = "2.2.2", build_tools = ["gleam"], requirements = ["argv", "booklet", "filepath", "gleam_community_ansi", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_httpc", "gleam_json", "gleam_otp", "gleam_regexp", "gleam_stdlib", "glint", "group_registry", "justin", "lustre", "mist", "polly", "simplifile", "tom", "wisp"], otp_app = "lustre_dev_tools", source = "hex", outer_checksum = "C66A09B0B268B596F021971E4E4111E4B2B4A1DF5BDFE6C28A1E1FE4CA371F1A" }, 37 + { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, 38 + { name = "mist", version = "5.0.3", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7C4BE717A81305323C47C8A591E6B9BA4AC7F56354BF70B4D3DF08CC01192668" }, 39 + { name = "modem", version = "2.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib", "lustre"], otp_app = "modem", source = "hex", outer_checksum = "3F9682EBCBF4D26045F1038A7507E8C7967E49D43F9CA6BA68EF0C971B195A7F" }, 40 + { name = "platform", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "platform", source = "hex", outer_checksum = "8339420A95AD89AAC0F82F4C3DB8DD401041742D6C3F46132A8739F6AEB75391" }, 41 + { name = "polly", version = "2.1.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib", "simplifile"], otp_app = "polly", source = "hex", outer_checksum = "1BA4D0ACE9BCF52AEA6AD9DE020FD8220CCA399A379E50A1775FC5C1204FCF56" }, 42 + { name = "simplifile", version = "2.3.1", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "957E0E5B75927659F1D2A1B7B75D7B9BA96FAA8D0C53EA71C4AD9CD0C6B848F6" }, 43 + { name = "snag", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "snag", source = "hex", outer_checksum = "274F41D6C3ECF99F7686FDCE54183333E41D2C1CA5A3A673F9A8B2C7A4401077" }, 44 + { name = "squall", version = "1.0.1", build_tools = ["gleam"], requirements = ["argv", "filepath", "glam", "gleam_http", "gleam_httpc", "gleam_json", "gleam_stdlib", "simplifile", "swell"], source = "local", path = "../../squall" }, 45 + { name = "squall_cache", version = "0.1.0", build_tools = ["gleam"], requirements = ["gleam_fetch", "gleam_http", "gleam_javascript", "gleam_json", "gleam_stdlib", "lustre", "squall"], source = "local", path = "../../squall_cache" }, 46 + { name = "swell", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "swell", source = "hex", outer_checksum = "CA28A95AB9A01E51D1FCFC0FC4F7102C7F47D04B9711138248D08C2A9F1FE6A3" }, 47 + { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" }, 48 + { name = "tom", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "74D0C5A3761F7A7D06994755D4D5AD854122EF8E9F9F76A3E7547606D8C77091" }, 49 + { name = "wisp", version = "2.1.0", build_tools = ["gleam"], requirements = ["directories", "exception", "filepath", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_stdlib", "houdini", "logging", "marceau", "mist", "simplifile"], otp_app = "wisp", source = "hex", outer_checksum = "362BDDD11BF48EB38CDE51A73BC7D1B89581B395CA998E3F23F11EC026151C54" }, 50 + ] 51 + 52 + [requirements] 53 + gleam_http = { version = ">= 4.3.0 and < 5.0.0" } 54 + gleam_json = { version = ">= 3.0.0 and < 4.0.0" } 55 + gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } 56 + gleeunit = { version = ">= 1.0.0 and < 2.0.0" } 57 + lustre = { version = ">= 5.0.0 and < 6.0.0" } 58 + lustre_dev_tools = { version = ">= 2.2.2 and < 3.0.0" } 59 + modem = { version = ">= 2.0.0 and < 3.0.0" } 60 + squall = { path = "../../squall" } 61 + squall_cache = { path = "../../squall_cache" }
+272
client/src/components/activity_chart.gleam
··· 1 + /// Activity Chart Component 2 + /// 3 + /// Displays stacked bar chart of Jetstream activity with time range selection 4 + /// 5 + /// ```graphql 6 + /// query GetActivityBuckets($range: TimeRange!) { 7 + /// activityBuckets(range: $range) { 8 + /// timestamp 9 + /// total 10 + /// creates 11 + /// updates 12 + /// deletes 13 + /// } 14 + /// } 15 + /// ``` 16 + import generated/queries/get_activity_buckets.{ 17 + type TimeRange, ONEDAY, ONEHOUR, SEVENDAYS, SIXHOURS, THREEHOURS, 18 + } 19 + import gleam/float 20 + import gleam/int 21 + import gleam/json 22 + import gleam/list 23 + import gleam/result 24 + import lustre/attribute 25 + import lustre/element.{type Element} 26 + import lustre/element/html 27 + import lustre/element/svg 28 + import lustre/event 29 + import squall_cache.{type Cache} 30 + 31 + pub fn view( 32 + cache: Cache, 33 + range: TimeRange, 34 + on_range_change: fn(TimeRange) -> msg, 35 + ) -> Element(msg) { 36 + html.div([attribute.class("bg-zinc-800/50 rounded p-4 font-mono mb-8")], [ 37 + render_time_range_buttons(range, on_range_change), 38 + render_chart(cache, range), 39 + ]) 40 + } 41 + 42 + fn render_time_range_buttons( 43 + current_range: TimeRange, 44 + on_range_change: fn(TimeRange) -> msg, 45 + ) -> Element(msg) { 46 + let get_button_class = fn(range: TimeRange) { 47 + let base = "px-3 py-1 text-xs rounded transition-colors cursor-pointer" 48 + case range == current_range { 49 + True -> base <> " bg-zinc-700 text-zinc-100" 50 + False -> 51 + base 52 + <> " bg-zinc-800/50 text-zinc-400 hover:bg-zinc-700/50 hover:text-zinc-300" 53 + } 54 + } 55 + 56 + html.div([attribute.class("flex gap-2 mb-4")], [ 57 + html.button( 58 + [ 59 + attribute.class(get_button_class(ONEHOUR)), 60 + event.on_click(on_range_change(ONEHOUR)), 61 + ], 62 + [element.text("1hr")], 63 + ), 64 + html.button( 65 + [ 66 + attribute.class(get_button_class(THREEHOURS)), 67 + event.on_click(on_range_change(THREEHOURS)), 68 + ], 69 + [element.text("3hr")], 70 + ), 71 + html.button( 72 + [ 73 + attribute.class(get_button_class(SIXHOURS)), 74 + event.on_click(on_range_change(SIXHOURS)), 75 + ], 76 + [element.text("6hr")], 77 + ), 78 + html.button( 79 + [ 80 + attribute.class(get_button_class(ONEDAY)), 81 + event.on_click(on_range_change(ONEDAY)), 82 + ], 83 + [element.text("1 day")], 84 + ), 85 + html.button( 86 + [ 87 + attribute.class(get_button_class(SEVENDAYS)), 88 + event.on_click(on_range_change(SEVENDAYS)), 89 + ], 90 + [element.text("7 day")], 91 + ), 92 + ]) 93 + } 94 + 95 + fn render_chart(cache: Cache, range: TimeRange) -> Element(msg) { 96 + let variables = 97 + json.object([ 98 + #("range", json.string(get_activity_buckets.time_range_to_string(range))), 99 + ]) 100 + 101 + let #(_cache, result) = 102 + squall_cache.lookup( 103 + cache, 104 + "GetActivityBuckets", 105 + variables, 106 + get_activity_buckets.parse_get_activity_buckets_response, 107 + ) 108 + 109 + case result { 110 + squall_cache.Loading -> 111 + html.div([attribute.class("py-8 text-center text-zinc-600 text-xs")], [ 112 + element.text("Loading activity data..."), 113 + ]) 114 + 115 + squall_cache.Failed(msg) -> 116 + html.div([attribute.class("py-8 text-center text-red-400 text-xs")], [ 117 + element.text("Error: " <> msg), 118 + ]) 119 + 120 + squall_cache.Data(data) -> { 121 + case data.activity_buckets { 122 + [] -> 123 + html.div([attribute.class("py-8 text-center text-zinc-600 text-xs")], [ 124 + element.text("No activity data available"), 125 + ]) 126 + buckets -> render_stacked_bar_chart(buckets, range) 127 + } 128 + } 129 + } 130 + } 131 + 132 + fn render_stacked_bar_chart( 133 + buckets: List(get_activity_buckets.ActivityBucket), 134 + range: TimeRange, 135 + ) -> Element(msg) { 136 + let max_value = calculate_max_value(buckets) 137 + let #(bar_width, gap) = case range { 138 + SEVENDAYS -> #(160.0, 12.0) 139 + _ -> #(30.0, 4.0) 140 + } 141 + let num_buckets = list.length(buckets) 142 + let chart_width = 143 + int.to_float(num_buckets) 144 + *. bar_width 145 + +. int.to_float(num_buckets - 1) 146 + *. gap 147 + let chart_height = 120.0 148 + 149 + html.div([attribute.class("w-full")], [ 150 + svg.svg( 151 + [ 152 + attribute.attribute( 153 + "viewBox", 154 + "0 0 " 155 + <> float.to_string(chart_width) 156 + <> " " 157 + <> float.to_string(chart_height), 158 + ), 159 + attribute.attribute("width", "100%"), 160 + attribute.attribute("height", float.to_string(chart_height)), 161 + attribute.attribute("style", "min-height: 120px"), 162 + attribute.attribute("preserveAspectRatio", "none"), 163 + ], 164 + list.index_map(buckets, fn(bucket, index) { 165 + render_stacked_bar(bucket, index, bar_width, gap, chart_height, max_value) 166 + }), 167 + ), 168 + ]) 169 + } 170 + 171 + fn calculate_max_value(buckets: List(get_activity_buckets.ActivityBucket)) -> Int { 172 + buckets 173 + |> list.map(fn(b) { b.creates + b.updates + b.deletes }) 174 + |> list.reduce(int.max) 175 + |> result.unwrap(1) 176 + } 177 + 178 + fn render_stacked_bar( 179 + bucket: get_activity_buckets.ActivityBucket, 180 + index: Int, 181 + bar_width: Float, 182 + gap: Float, 183 + chart_height: Float, 184 + max_value: Int, 185 + ) -> Element(msg) { 186 + let x = int.to_float(index) *. { bar_width +. gap } 187 + let total = bucket.creates + bucket.updates + bucket.deletes 188 + 189 + case total { 190 + 0 -> { 191 + // Render placeholder bar for empty bins 192 + let placeholder_height = 4.0 193 + let placeholder_y = chart_height -. placeholder_height 194 + svg.g([], [ 195 + svg.rect([ 196 + attribute.attribute("x", float.to_string(x)), 197 + attribute.attribute("y", float.to_string(placeholder_y)), 198 + attribute.attribute("width", float.to_string(bar_width)), 199 + attribute.attribute("height", float.to_string(placeholder_height)), 200 + attribute.attribute( 201 + "style", 202 + "fill: #3f3f46 !important; stroke: none; display: inline", 203 + ), 204 + ]), 205 + ]) 206 + } 207 + _ -> { 208 + let scale = chart_height /. int.to_float(max_value) 209 + 210 + // Calculate heights for each segment 211 + let delete_height = int.to_float(bucket.deletes) *. scale 212 + let update_height = int.to_float(bucket.updates) *. scale 213 + let create_height = int.to_float(bucket.creates) *. scale 214 + 215 + // Calculate y positions (bottom to top: delete, update, create) 216 + let delete_y = chart_height -. delete_height 217 + let update_y = delete_y -. update_height 218 + let create_y = update_y -. create_height 219 + 220 + svg.g([attribute.class("group")], [ 221 + // Delete segment (red) - bottom 222 + case bucket.deletes > 0 { 223 + True -> 224 + svg.rect([ 225 + attribute.attribute("x", float.to_string(x)), 226 + attribute.attribute("y", float.to_string(delete_y)), 227 + attribute.attribute("width", float.to_string(bar_width)), 228 + attribute.attribute("height", float.to_string(delete_height)), 229 + attribute.attribute( 230 + "style", 231 + "fill: #ef4444 !important; stroke: none; display: inline; cursor: pointer; transition: opacity 0.2s", 232 + ), 233 + attribute.class("group-hover:opacity-80"), 234 + ]) 235 + False -> element.none() 236 + }, 237 + // Update segment (blue) - middle 238 + case bucket.updates > 0 { 239 + True -> 240 + svg.rect([ 241 + attribute.attribute("x", float.to_string(x)), 242 + attribute.attribute("y", float.to_string(update_y)), 243 + attribute.attribute("width", float.to_string(bar_width)), 244 + attribute.attribute("height", float.to_string(update_height)), 245 + attribute.attribute( 246 + "style", 247 + "fill: #60a5fa !important; stroke: none; display: inline; cursor: pointer; transition: opacity 0.2s", 248 + ), 249 + attribute.class("group-hover:opacity-80"), 250 + ]) 251 + False -> element.none() 252 + }, 253 + // Create segment (green) - top 254 + case bucket.creates > 0 { 255 + True -> 256 + svg.rect([ 257 + attribute.attribute("x", float.to_string(x)), 258 + attribute.attribute("y", float.to_string(create_y)), 259 + attribute.attribute("width", float.to_string(bar_width)), 260 + attribute.attribute("height", float.to_string(create_height)), 261 + attribute.attribute( 262 + "style", 263 + "fill: #22c55e !important; stroke: none; display: inline; cursor: pointer; transition: opacity 0.2s", 264 + ), 265 + attribute.class("group-hover:opacity-80"), 266 + ]) 267 + False -> element.none() 268 + }, 269 + ]) 270 + } 271 + } 272 + }
+242
client/src/components/activity_log.gleam
··· 1 + /// Activity Log Component 2 + /// 3 + /// Displays recent jetstream activity entries 4 + /// 5 + /// ```graphql 6 + /// query GetRecentActivity($hours: Int!) { 7 + /// recentActivity(hours: $hours) { 8 + /// id 9 + /// timestamp 10 + /// operation 11 + /// collection 12 + /// did 13 + /// status 14 + /// errorMessage 15 + /// eventJson 16 + /// } 17 + /// } 18 + /// ``` 19 + import date_formatter 20 + import gleam/int 21 + import gleam/json 22 + import gleam/list 23 + import gleam/option 24 + import generated/queries/get_recent_activity 25 + import json_formatter 26 + import lustre/attribute 27 + import lustre/element.{type Element} 28 + import lustre/element/html 29 + import squall_cache.{type Cache} 30 + 31 + pub fn view(cache: Cache, hours: Int) -> Element(msg) { 32 + let variables = json.object([#("hours", json.int(hours))]) 33 + 34 + let #(_cache, result) = 35 + squall_cache.lookup( 36 + cache, 37 + "GetRecentActivity", 38 + variables, 39 + get_recent_activity.parse_get_recent_activity_response, 40 + ) 41 + 42 + html.div([attribute.class("font-mono mb-8")], [ 43 + // CSS for expandable details 44 + element.element( 45 + "style", 46 + [], 47 + [ 48 + element.text( 49 + "[data-entry-id].expanded [data-caret] { transform: rotate(90deg); } 50 + [data-entry-id].expanded [data-details] { display: block !important; }", 51 + ), 52 + ], 53 + ), 54 + html.div([attribute.class("bg-zinc-800/50 rounded p-4")], [ 55 + // Header 56 + html.div([attribute.class("flex items-center justify-between mb-3")], [ 57 + html.div([attribute.class("text-sm text-zinc-500")], [ 58 + element.text("JetStream Activity"), 59 + ]), 60 + case result { 61 + squall_cache.Data(data) -> 62 + html.span([attribute.class("text-xs text-zinc-600")], [ 63 + element.text( 64 + int.to_string(list.length(data.recent_activity)) 65 + <> " events (" 66 + <> int.to_string(hours) 67 + <> "h)", 68 + ), 69 + ]) 70 + _ -> 71 + html.span([attribute.class("text-xs text-zinc-600")], [ 72 + element.text("(" <> int.to_string(hours) <> "h)"), 73 + ]) 74 + }, 75 + ]), 76 + // Activity list - scrollable 77 + html.div([attribute.class("max-h-80 overflow-y-auto")], [ 78 + case result { 79 + squall_cache.Loading -> 80 + html.div([attribute.class("py-8 text-center text-zinc-600 text-xs")], [ 81 + element.text("Loading activity..."), 82 + ]) 83 + 84 + squall_cache.Failed(msg) -> 85 + html.div([attribute.class("py-8 text-center text-red-400 text-xs")], [ 86 + element.text("Error: " <> msg), 87 + ]) 88 + 89 + squall_cache.Data(data) -> { 90 + case data.recent_activity { 91 + [] -> 92 + html.div([attribute.class("py-8 text-center text-zinc-600 text-xs")], [ 93 + element.text("No activity in the last " <> int.to_string(hours) <> " hours"), 94 + ]) 95 + entries -> render_activity_entries(entries) 96 + } 97 + } 98 + }, 99 + ]), 100 + ]), 101 + ]) 102 + } 103 + 104 + fn render_activity_entries( 105 + entries: List(get_recent_activity.ActivityEntry), 106 + ) -> Element(msg) { 107 + html.div( 108 + [], 109 + list.map(entries, fn(entry) { 110 + let status_color = case entry.status { 111 + "success" -> "text-green-500" 112 + "validation_error" -> "text-yellow-500" 113 + "error" -> "text-red-500" 114 + "processing" -> "text-blue-500" 115 + _ -> "text-zinc-500" 116 + } 117 + 118 + let status_icon = case entry.status { 119 + "success" -> "✓" 120 + "validation_error" -> "⚠" 121 + "error" -> "✗" 122 + "processing" -> "⋯" 123 + _ -> "•" 124 + } 125 + 126 + let operation_color = case entry.operation { 127 + "create" -> "text-green-400" 128 + "update" -> "text-blue-400" 129 + "delete" -> "text-red-400" 130 + _ -> "text-zinc-400" 131 + } 132 + 133 + let entry_id = "activity-" <> int.to_string(entry.id) 134 + 135 + html.div( 136 + [ 137 + attribute.class("border-l-2 border-zinc-700/50 hover:border-zinc-600 transition-colors"), 138 + attribute.attribute("data-entry-id", entry_id), 139 + ], 140 + [ 141 + // Main log line 142 + html.div( 143 + [ 144 + attribute.class("flex items-start gap-2 py-1 text-xs font-mono hover:bg-zinc-900/30 cursor-pointer group"), 145 + attribute.attribute("onclick", "this.parentElement.classList.toggle('expanded')"), 146 + ], 147 + [ 148 + // Caret for expansion (always visible) 149 + html.span( 150 + [ 151 + attribute.class("text-zinc-600 group-hover:text-zinc-400 shrink-0 select-none transition-transform caret"), 152 + attribute.attribute("data-caret", ""), 153 + ], 154 + [element.text("›")], 155 + ), 156 + // Timestamp - show time only in local timezone 157 + html.span( 158 + [ 159 + attribute.class("text-zinc-600 shrink-0 w-16"), 160 + attribute.attribute("data-timestamp", entry.timestamp), 161 + ], 162 + [element.text(date_formatter.format_time_local(entry.timestamp))], 163 + ), 164 + // Status icon 165 + html.span([attribute.class(status_color <> " shrink-0 w-4")], [ 166 + element.text(status_icon), 167 + ]), 168 + // Operation 169 + html.span([attribute.class(operation_color <> " shrink-0 w-12")], [ 170 + element.text(entry.operation), 171 + ]), 172 + // Collection - full name in purple 173 + html.span([attribute.class("text-purple-400 shrink-0")], [ 174 + element.text(entry.collection), 175 + ]), 176 + // DID - no truncation 177 + html.span([attribute.class("text-zinc-500 truncate")], [ 178 + element.text(entry.did), 179 + ]), 180 + ], 181 + ), 182 + // Expanded details section - shows all fields and JSON snippet 183 + html.div( 184 + [ 185 + attribute.class("px-6 py-2 text-xs bg-zinc-900/50 border-t border-zinc-800 hidden space-y-1"), 186 + attribute.attribute("data-details", ""), 187 + ], 188 + [ 189 + // Full timestamp in local timezone 190 + html.div([attribute.class("flex gap-2")], [ 191 + html.span([attribute.class("text-zinc-600 w-20")], [element.text("Timestamp:")]), 192 + html.span([attribute.class("text-zinc-400")], [element.text(date_formatter.format_datetime_local(entry.timestamp))]), 193 + ]), 194 + // Full DID 195 + html.div([attribute.class("flex gap-2")], [ 196 + html.span([attribute.class("text-zinc-600 w-20")], [element.text("DID:")]), 197 + html.span([attribute.class("text-zinc-400 font-mono break-all")], [element.text(entry.did)]), 198 + ]), 199 + // Status 200 + html.div([attribute.class("flex gap-2")], [ 201 + html.span([attribute.class("text-zinc-600 w-20")], [element.text("Status:")]), 202 + html.span([attribute.class(case entry.status { 203 + "success" -> "text-green-400" 204 + "validation_error" -> "text-yellow-400" 205 + "error" -> "text-red-400" 206 + _ -> "text-zinc-400" 207 + })], [element.text(entry.status)]), 208 + ]), 209 + // Error message (if present) 210 + case entry.error_message { 211 + option.Some(err_msg) -> 212 + html.div([attribute.class("flex gap-2")], [ 213 + html.span([attribute.class("text-zinc-600 w-20")], [element.text("Error:")]), 214 + html.span([attribute.class("text-red-400")], [element.text(err_msg)]), 215 + ]) 216 + option.None -> element.none() 217 + }, 218 + // JSON snippet - pretty printed with nested JSON expanded 219 + case entry.event_json { 220 + option.Some(json_str) -> { 221 + let pretty_json = json_formatter.pretty_print_nested(json_str) 222 + html.div([attribute.class("mt-2")], [ 223 + html.div([attribute.class("text-zinc-600 mb-1")], [element.text("Event JSON:")]), 224 + html.pre( 225 + [ 226 + attribute.class("text-zinc-400 bg-black/40 p-2 rounded text-[10px] whitespace-pre-wrap block"), 227 + attribute.attribute("data-json", json_str), 228 + ], 229 + [element.text(pretty_json)], 230 + ), 231 + ]) 232 + } 233 + option.None -> element.none() 234 + }, 235 + ], 236 + ), 237 + ], 238 + ) 239 + }), 240 + ) 241 + } 242 +
+78
client/src/components/alert.gleam
··· 1 + import lustre/attribute 2 + import lustre/element.{type Element} 3 + import lustre/element/html 4 + 5 + pub type AlertKind { 6 + Success 7 + Error 8 + Info 9 + Warning 10 + } 11 + 12 + /// Render an alert message with appropriate styling 13 + pub fn alert(kind: AlertKind, message: String) -> Element(msg) { 14 + let #(bg_class, border_class, text_class, icon) = case kind { 15 + Success -> #("bg-green-900/30", "border-green-800", "text-green-300", "✓") 16 + Error -> #("bg-red-900/30", "border-red-800", "text-red-300", "✗") 17 + Info -> #("bg-blue-900/30", "border-blue-800", "text-blue-300", "ℹ") 18 + Warning -> #("bg-yellow-900/30", "border-yellow-800", "text-yellow-300", "⚠") 19 + } 20 + 21 + html.div( 22 + [ 23 + attribute.class( 24 + "mb-6 p-4 rounded border " <> bg_class <> " " <> border_class, 25 + ), 26 + ], 27 + [ 28 + html.div([attribute.class("flex items-center gap-3")], [ 29 + html.span([attribute.class("text-lg " <> text_class)], [ 30 + element.text(icon), 31 + ]), 32 + html.span([attribute.class("text-sm " <> text_class)], [ 33 + element.text(message), 34 + ]), 35 + ]), 36 + ], 37 + ) 38 + } 39 + 40 + /// Render an alert message with a link 41 + pub fn alert_with_link( 42 + kind: AlertKind, 43 + message: String, 44 + link_text: String, 45 + link_url: String, 46 + ) -> Element(msg) { 47 + let #(bg_class, border_class, text_class, icon) = case kind { 48 + Success -> #("bg-green-900/30", "border-green-800", "text-green-300", "✓") 49 + Error -> #("bg-red-900/30", "border-red-800", "text-red-300", "✗") 50 + Info -> #("bg-blue-900/30", "border-blue-800", "text-blue-300", "ℹ") 51 + Warning -> #("bg-yellow-900/30", "border-yellow-800", "text-yellow-300", "⚠") 52 + } 53 + 54 + html.div( 55 + [ 56 + attribute.class( 57 + "mb-6 p-4 rounded border " <> bg_class <> " " <> border_class, 58 + ), 59 + ], 60 + [ 61 + html.div([attribute.class("flex items-center gap-3")], [ 62 + html.span([attribute.class("text-lg " <> text_class)], [ 63 + element.text(icon), 64 + ]), 65 + html.span([attribute.class("text-sm " <> text_class)], [ 66 + element.text(message <> " "), 67 + html.a( 68 + [ 69 + attribute.href(link_url), 70 + attribute.class("underline hover:no-underline"), 71 + ], 72 + [element.text(link_text)], 73 + ), 74 + ]), 75 + ]), 76 + ], 77 + ) 78 + }
+126
client/src/components/layout.gleam
··· 1 + import components/logo 2 + import gleam/list 3 + import gleam/option.{type Option} 4 + import lustre/attribute 5 + import lustre/element.{type Element} 6 + import lustre/element/html 7 + 8 + /// Renders the unified header with logo and nav 9 + /// auth_info: Option containing (handle, is_admin) if authenticated, None if not 10 + pub fn header(auth_info: Option(#(String, Bool))) -> Element(msg) { 11 + html.div([attribute.class("border-b border-zinc-800 pb-4 mb-8")], [ 12 + html.div([attribute.class("flex items-end justify-between")], [ 13 + // Left: Brand with logo 14 + html.a( 15 + [ 16 + attribute.href("/"), 17 + attribute.class( 18 + "flex items-center gap-3 hover:opacity-80 transition-opacity", 19 + ), 20 + ], 21 + [ 22 + logo.view("w-10 h-10"), 23 + html.div([], [ 24 + html.h1( 25 + [ 26 + attribute.class( 27 + "text-xs font-medium uppercase tracking-wider text-zinc-500", 28 + ), 29 + ], 30 + [element.text("quickslice")], 31 + ), 32 + ]), 33 + ], 34 + ), 35 + // Right: Navigation and Auth 36 + html.div([attribute.class("flex gap-4 text-xs items-center")], case auth_info { 37 + option.None -> [ 38 + // Only show login form when not authenticated 39 + html.form( 40 + [ 41 + attribute.method("POST"), 42 + attribute.action("/oauth/authorize"), 43 + attribute.class("flex gap-2 items-center"), 44 + ], 45 + [ 46 + html.input([ 47 + attribute.type_("text"), 48 + attribute.name("login_hint"), 49 + attribute.placeholder("handle.bsky.social"), 50 + attribute.class( 51 + "bg-zinc-900 border border-zinc-700 rounded px-2 py-1 text-xs text-zinc-300 placeholder-zinc-600 focus:border-zinc-500 focus:outline-none w-48", 52 + ), 53 + attribute.required(True), 54 + ]), 55 + html.button( 56 + [ 57 + attribute.type_("submit"), 58 + attribute.class( 59 + "bg-zinc-800 hover:bg-zinc-700 text-zinc-300 px-3 py-1 rounded transition-colors", 60 + ), 61 + ], 62 + [element.text("Login")], 63 + ), 64 + ], 65 + ), 66 + ] 67 + option.Some(#(handle, is_admin)) -> { 68 + // Show navigation links when authenticated 69 + let nav_links = [ 70 + html.a( 71 + [ 72 + attribute.href("/"), 73 + attribute.class( 74 + "px-3 py-1 text-zinc-400 hover:text-zinc-300 transition-colors", 75 + ), 76 + ], 77 + [element.text("Home")], 78 + ), 79 + ] 80 + 81 + // Add Settings link only for admins 82 + let nav_links = case is_admin { 83 + True -> 84 + list.append(nav_links, [ 85 + html.a( 86 + [ 87 + attribute.href("/settings"), 88 + attribute.class( 89 + "px-3 py-1 text-zinc-400 hover:text-zinc-300 transition-colors", 90 + ), 91 + ], 92 + [element.text("Settings")], 93 + ), 94 + ]) 95 + False -> nav_links 96 + } 97 + 98 + list.append(nav_links, [ 99 + // User handle 100 + html.span([attribute.class("px-3 py-1 text-zinc-400")], [ 101 + element.text(handle), 102 + ]), 103 + // Logout button 104 + html.form( 105 + [ 106 + attribute.method("POST"), 107 + attribute.action("/logout"), 108 + ], 109 + [ 110 + html.button( 111 + [ 112 + attribute.type_("submit"), 113 + attribute.class( 114 + "px-3 py-1 text-zinc-400 hover:text-zinc-300 transition-colors cursor-pointer", 115 + ), 116 + ], 117 + [element.text("Logout")], 118 + ), 119 + ], 120 + ), 121 + ]) 122 + } 123 + }), 124 + ]), 125 + ]) 126 + }
+76
client/src/components/stats_cards.gleam
··· 1 + /// Stats Cards Component 2 + /// 3 + /// Displays system statistics (record count, actor count, lexicon count) 4 + /// 5 + /// ```graphql 6 + /// query GetStatistics { 7 + /// statistics { 8 + /// recordCount 9 + /// actorCount 10 + /// lexiconCount 11 + /// } 12 + /// } 13 + /// ``` 14 + import gleam/json 15 + import generated/queries/get_statistics 16 + import lustre/attribute 17 + import lustre/element.{type Element} 18 + import lustre/element/html 19 + import number_formatter 20 + import squall_cache.{type Cache} 21 + 22 + pub fn view(cache: Cache) -> Element(msg) { 23 + let #(_cache, result) = 24 + squall_cache.lookup( 25 + cache, 26 + "GetStatistics", 27 + json.object([]), 28 + get_statistics.parse_get_statistics_response, 29 + ) 30 + 31 + case result { 32 + squall_cache.Loading -> 33 + html.div([attribute.class("mb-8 grid grid-cols-3 gap-4")], [ 34 + loading_card("Total Records"), 35 + loading_card("Total Actors"), 36 + loading_card("Total Lexicons"), 37 + ]) 38 + 39 + squall_cache.Failed(msg) -> 40 + html.div([attribute.class("mb-8")], [ 41 + html.div([attribute.class("bg-red-800/50 rounded p-4 text-red-200")], [ 42 + html.text("Error loading statistics: " <> msg), 43 + ]), 44 + ]) 45 + 46 + squall_cache.Data(data) -> { 47 + let stats = data.statistics 48 + 49 + html.div([attribute.class("mb-8 grid grid-cols-3 gap-4")], [ 50 + stat_card("Total Records", number_formatter.format_number(stats.record_count)), 51 + stat_card("Total Actors", number_formatter.format_number(stats.actor_count)), 52 + stat_card("Total Lexicons", number_formatter.format_number(stats.lexicon_count)), 53 + ]) 54 + } 55 + } 56 + } 57 + 58 + fn stat_card(label: String, value: String) -> Element(msg) { 59 + html.div([attribute.class("bg-zinc-800/50 rounded p-4")], [ 60 + html.div([attribute.class("text-sm text-zinc-500 mb-1")], [ 61 + html.text(label), 62 + ]), 63 + html.div([attribute.class("text-2xl font-semibold text-zinc-200")], [ 64 + html.text(value), 65 + ]), 66 + ]) 67 + } 68 + 69 + fn loading_card(label: String) -> Element(msg) { 70 + html.div([attribute.class("bg-zinc-800/50 rounded p-4 animate-pulse")], [ 71 + html.div([attribute.class("text-sm text-zinc-500 mb-1")], [ 72 + html.text(label), 73 + ]), 74 + html.div([attribute.class("h-8 bg-zinc-700 rounded w-24")], []), 75 + ]) 76 + }
+34
client/src/date_formatter.ffi.mjs
··· 1 + /// JavaScript FFI for date formatting 2 + 3 + export function formatTimeLocal(isoTimestamp) { 4 + try { 5 + const date = new Date(isoTimestamp); 6 + // Format as HH:MM:SS in local timezone 7 + return date.toLocaleTimeString("en-US", { 8 + hour: "2-digit", 9 + minute: "2-digit", 10 + second: "2-digit", 11 + hour12: false, 12 + }); 13 + } catch (_e) { 14 + return isoTimestamp; 15 + } 16 + } 17 + 18 + export function formatDateTimeLocal(isoTimestamp) { 19 + try { 20 + const date = new Date(isoTimestamp); 21 + // Format as full date and time in local timezone 22 + return date.toLocaleString("en-US", { 23 + year: "numeric", 24 + month: "2-digit", 25 + day: "2-digit", 26 + hour: "2-digit", 27 + minute: "2-digit", 28 + second: "2-digit", 29 + hour12: false, 30 + }); 31 + } catch (_e) { 32 + return isoTimestamp; 33 + } 34 + }
+9
client/src/date_formatter.gleam
··· 1 + /// Date formatting utilities via JavaScript FFI 2 + 3 + /// Format an ISO8601 timestamp to local time (HH:MM:SS in user's timezone) 4 + @external(javascript, "./date_formatter.ffi.mjs", "formatTimeLocal") 5 + pub fn format_time_local(iso_timestamp: String) -> String 6 + 7 + /// Format an ISO8601 timestamp to full local datetime 8 + @external(javascript, "./date_formatter.ffi.mjs", "formatDateTimeLocal") 9 + pub fn format_datetime_local(iso_timestamp: String) -> String
+62
client/src/file_upload.ffi.mjs
··· 1 + /// JavaScript FFI for file uploads 2 + 3 + import { Error, Ok } from "./gleam.mjs"; 4 + 5 + /** 6 + * Read a file from an input element and encode it as base64 7 + * This is designed to work with Lustre's effect system by handling async via callbacks 8 + * @param {string} fileInputId - The ID of the file input element 9 + * @param {function} dispatch - The dispatch function to call with the result 10 + * @returns {void} 11 + */ 12 + export function readFileAsBase64(fileInputId, dispatch) { 13 + console.log("[readFileAsBase64] Called with fileInputId:", fileInputId); 14 + const input = document.getElementById(fileInputId); 15 + 16 + if (!input) { 17 + console.log("[readFileAsBase64] File input not found"); 18 + dispatch(new Error("File input not found")); 19 + return; 20 + } 21 + 22 + console.log("[readFileAsBase64] Input element:", input); 23 + console.log("[readFileAsBase64] Input files:", input.files); 24 + const file = input.files?.[0]; 25 + 26 + if (!file) { 27 + console.log("[readFileAsBase64] No file selected"); 28 + dispatch(new Error("No file selected")); 29 + return; 30 + } 31 + 32 + console.log("[readFileAsBase64] Reading file:", file.name); 33 + 34 + const reader = new FileReader(); 35 + 36 + reader.onload = (e) => { 37 + try { 38 + const base64 = e.target.result.split(",")[1]; // Remove data:... prefix 39 + dispatch(new Ok(base64)); 40 + } catch (err) { 41 + dispatch(new Error(`Failed to encode file: ${err.message}`)); 42 + } 43 + }; 44 + 45 + reader.onerror = () => { 46 + dispatch(new Error("Failed to read file")); 47 + }; 48 + 49 + reader.readAsDataURL(file); 50 + } 51 + 52 + /** 53 + * Clear a file input element 54 + * @param {string} fileInputId - The ID of the file input element 55 + * @returns {void} 56 + */ 57 + export function clearFileInput(fileInputId) { 58 + const input = document.getElementById(fileInputId); 59 + if (input) { 60 + input.value = ''; 61 + } 62 + }
+15
client/src/file_upload.gleam
··· 1 + /// File upload utilities via JavaScript FFI 2 + /// 3 + /// Provides base64 encoding for file uploads through GraphQL 4 + 5 + /// Read a file and encode it as base64 6 + /// This is async and uses a callback (dispatch function) to return the result 7 + @external(javascript, "./file_upload.ffi.mjs", "readFileAsBase64") 8 + pub fn read_file_as_base64( 9 + file_input_id: String, 10 + dispatch: fn(Result(String, String)) -> Nil, 11 + ) -> Nil 12 + 13 + /// Clear a file input element 14 + @external(javascript, "./file_upload.ffi.mjs", "clearFileInput") 15 + pub fn clear_file_input(file_input_id: String) -> Nil
+62
client/src/generated/queries.gleam
··· 1 + import squall/registry 2 + 3 + /// Initialize the query registry with all extracted queries 4 + /// This function is auto-generated by Squall 5 + pub fn init_registry() -> registry.Registry { 6 + let reg = registry.new() 7 + let reg = registry.register( 8 + reg, 9 + "TriggerBackfill", 10 + "mutation TriggerBackfill {\n triggerBackfill\n}", 11 + "generated/queries/trigger_backfill", 12 + ) 13 + let reg = registry.register( 14 + reg, 15 + "GetCurrentSession", 16 + "query GetCurrentSession {\n currentSession {\n __typename\n did\n handle\n isAdmin\n }\n}", 17 + "generated/queries/get_current_session", 18 + ) 19 + let reg = registry.register( 20 + reg, 21 + "GetActivityBuckets", 22 + "query GetActivityBuckets($range: TimeRange!) {\n activityBuckets(range: $range) {\n __typename\n timestamp\n total\n creates\n updates\n deletes\n }\n}", 23 + "generated/queries/get_activity_buckets", 24 + ) 25 + let reg = registry.register( 26 + reg, 27 + "GetRecentActivity", 28 + "query GetRecentActivity($hours: Int!) {\n recentActivity(hours: $hours) {\n __typename\n id\n timestamp\n operation\n collection\n did\n status\n errorMessage\n eventJson\n }\n}", 29 + "generated/queries/get_recent_activity", 30 + ) 31 + let reg = registry.register( 32 + reg, 33 + "GetStatistics", 34 + "query GetStatistics {\n statistics {\n __typename\n recordCount\n actorCount\n lexiconCount\n }\n}", 35 + "generated/queries/get_statistics", 36 + ) 37 + let reg = registry.register( 38 + reg, 39 + "GetSettings", 40 + "query GetSettings {\n settings {\n __typename\n id\n domainAuthority\n oauthClientId\n }\n}", 41 + "generated/queries/get_settings", 42 + ) 43 + let reg = registry.register( 44 + reg, 45 + "UpdateDomainAuthority", 46 + "mutation UpdateDomainAuthority($domainAuthority: String!) {\n updateDomainAuthority(domainAuthority: $domainAuthority) {\n __typename\n id\n domainAuthority\n oauthClientId\n }\n}", 47 + "generated/queries/update_domain_authority", 48 + ) 49 + let reg = registry.register( 50 + reg, 51 + "UploadLexicons", 52 + "mutation UploadLexicons($zipBase64: String!) {\n uploadLexicons(zipBase64: $zipBase64)\n}", 53 + "generated/queries/upload_lexicons", 54 + ) 55 + let reg = registry.register( 56 + reg, 57 + "ResetAll", 58 + "mutation ResetAll($confirm: String!) {\n resetAll(confirm: $confirm)\n}", 59 + "generated/queries/reset_all", 60 + ) 61 + reg 62 + }
+110
client/src/generated/queries/get_activity_buckets.gleam
··· 1 + import gleam/dynamic/decode 2 + import gleam/http/request.{type Request} 3 + import gleam/json 4 + import squall 5 + 6 + pub type TimeRange { 7 + ONEHOUR 8 + THREEHOURS 9 + SIXHOURS 10 + ONEDAY 11 + SEVENDAYS 12 + } 13 + 14 + pub fn time_range_to_string(value: TimeRange) -> String { 15 + case value { 16 + ONEHOUR -> "ONE_HOUR" 17 + THREEHOURS -> "THREE_HOURS" 18 + SIXHOURS -> "SIX_HOURS" 19 + ONEDAY -> "ONE_DAY" 20 + SEVENDAYS -> "SEVEN_DAYS" 21 + } 22 + } 23 + 24 + pub fn time_range_decoder() -> decode.Decoder(TimeRange) { 25 + decode.string 26 + 27 + 28 + |> decode.then(fn(str) { 29 + 30 + case str { 31 + "ONE_HOUR" -> decode.success(ONEHOUR) 32 + "THREE_HOURS" -> decode.success(THREEHOURS) 33 + "SIX_HOURS" -> decode.success(SIXHOURS) 34 + "ONE_DAY" -> decode.success(ONEDAY) 35 + "SEVEN_DAYS" -> decode.success(SEVENDAYS) 36 + _other -> decode.failure(ONEHOUR, "TimeRange") 37 + } 38 + 39 + 40 + }) 41 + } 42 + 43 + pub type ActivityBucket { 44 + ActivityBucket( 45 + timestamp: String, 46 + total: Int, 47 + creates: Int, 48 + updates: Int, 49 + deletes: Int, 50 + ) 51 + } 52 + 53 + pub fn activity_bucket_decoder() -> decode.Decoder(ActivityBucket) { 54 + use timestamp <- decode.field("timestamp", decode.string) 55 + use total <- decode.field("total", decode.int) 56 + use creates <- decode.field("creates", decode.int) 57 + use updates <- decode.field("updates", decode.int) 58 + use deletes <- decode.field("deletes", decode.int) 59 + decode.success(ActivityBucket( 60 + timestamp: timestamp, 61 + total: total, 62 + creates: creates, 63 + updates: updates, 64 + deletes: deletes, 65 + )) 66 + } 67 + 68 + pub fn activity_bucket_to_json(input: ActivityBucket) -> json.Json { 69 + json.object( 70 + [ 71 + #("timestamp", json.string(input.timestamp)), 72 + #("total", json.int(input.total)), 73 + #("creates", json.int(input.creates)), 74 + #("updates", json.int(input.updates)), 75 + #("deletes", json.int(input.deletes)), 76 + ], 77 + ) 78 + } 79 + 80 + pub type GetActivityBucketsResponse { 81 + GetActivityBucketsResponse(activity_buckets: List(ActivityBucket)) 82 + } 83 + 84 + pub fn get_activity_buckets_response_decoder() -> decode.Decoder(GetActivityBucketsResponse) { 85 + use activity_buckets <- decode.field("activityBuckets", decode.list(activity_bucket_decoder())) 86 + decode.success(GetActivityBucketsResponse(activity_buckets: activity_buckets)) 87 + } 88 + 89 + pub fn get_activity_buckets_response_to_json(input: GetActivityBucketsResponse) -> json.Json { 90 + json.object( 91 + [ 92 + #("activityBuckets", json.array( 93 + from: input.activity_buckets, 94 + of: activity_bucket_to_json, 95 + )), 96 + ], 97 + ) 98 + } 99 + 100 + pub fn get_activity_buckets(client: squall.Client, range: TimeRange) -> Result(Request(String), String) { 101 + squall.prepare_request( 102 + client, 103 + "query GetActivityBuckets($range: TimeRange!) {\n activityBuckets(range: $range) {\n timestamp\n total\n creates\n updates\n deletes\n }\n}", 104 + json.object([#("range", json.string(time_range_to_string(range)))]), 105 + ) 106 + } 107 + 108 + pub fn parse_get_activity_buckets_response(body: String) -> Result(GetActivityBucketsResponse, String) { 109 + squall.parse_response(body, get_activity_buckets_response_decoder()) 110 + }
+58
client/src/generated/queries/get_current_session.gleam
··· 1 + import gleam/dynamic/decode 2 + import gleam/http/request.{type Request} 3 + import gleam/json 4 + import squall 5 + import gleam/option.{type Option} 6 + 7 + pub type CurrentSession { 8 + CurrentSession(did: String, handle: String, is_admin: Bool) 9 + } 10 + 11 + pub fn current_session_decoder() -> decode.Decoder(CurrentSession) { 12 + use did <- decode.field("did", decode.string) 13 + use handle <- decode.field("handle", decode.string) 14 + use is_admin <- decode.field("isAdmin", decode.bool) 15 + decode.success(CurrentSession(did: did, handle: handle, is_admin: is_admin)) 16 + } 17 + 18 + pub fn current_session_to_json(input: CurrentSession) -> json.Json { 19 + json.object( 20 + [ 21 + #("did", json.string(input.did)), 22 + #("handle", json.string(input.handle)), 23 + #("isAdmin", json.bool(input.is_admin)), 24 + ], 25 + ) 26 + } 27 + 28 + pub type GetCurrentSessionResponse { 29 + GetCurrentSessionResponse(current_session: Option(CurrentSession)) 30 + } 31 + 32 + pub fn get_current_session_response_decoder() -> decode.Decoder(GetCurrentSessionResponse) { 33 + use current_session <- decode.field("currentSession", decode.optional(current_session_decoder())) 34 + decode.success(GetCurrentSessionResponse(current_session: current_session)) 35 + } 36 + 37 + pub fn get_current_session_response_to_json(input: GetCurrentSessionResponse) -> json.Json { 38 + json.object( 39 + [ 40 + #("currentSession", json.nullable( 41 + input.current_session, 42 + current_session_to_json, 43 + )), 44 + ], 45 + ) 46 + } 47 + 48 + pub fn get_current_session(client: squall.Client) -> Result(Request(String), String) { 49 + squall.prepare_request( 50 + client, 51 + "query GetCurrentSession {\n currentSession {\n did\n handle\n isAdmin\n }\n}", 52 + json.object([]), 53 + ) 54 + } 55 + 56 + pub fn parse_get_current_session_response(body: String) -> Result(GetCurrentSessionResponse, String) { 57 + squall.parse_response(body, get_current_session_response_decoder()) 58 + }
+86
client/src/generated/queries/get_recent_activity.gleam
··· 1 + import gleam/dynamic/decode 2 + import gleam/http/request.{type Request} 3 + import gleam/json 4 + import squall 5 + import gleam/option.{type Option} 6 + 7 + pub type ActivityEntry { 8 + ActivityEntry( 9 + id: Int, 10 + timestamp: String, 11 + operation: String, 12 + collection: String, 13 + did: String, 14 + status: String, 15 + error_message: Option(String), 16 + event_json: Option(String), 17 + ) 18 + } 19 + 20 + pub fn activity_entry_decoder() -> decode.Decoder(ActivityEntry) { 21 + use id <- decode.field("id", decode.int) 22 + use timestamp <- decode.field("timestamp", decode.string) 23 + use operation <- decode.field("operation", decode.string) 24 + use collection <- decode.field("collection", decode.string) 25 + use did <- decode.field("did", decode.string) 26 + use status <- decode.field("status", decode.string) 27 + use error_message <- decode.field("errorMessage", decode.optional(decode.string)) 28 + use event_json <- decode.field("eventJson", decode.optional(decode.string)) 29 + decode.success(ActivityEntry( 30 + id: id, 31 + timestamp: timestamp, 32 + operation: operation, 33 + collection: collection, 34 + did: did, 35 + status: status, 36 + error_message: error_message, 37 + event_json: event_json, 38 + )) 39 + } 40 + 41 + pub fn activity_entry_to_json(input: ActivityEntry) -> json.Json { 42 + json.object( 43 + [ 44 + #("id", json.int(input.id)), 45 + #("timestamp", json.string(input.timestamp)), 46 + #("operation", json.string(input.operation)), 47 + #("collection", json.string(input.collection)), 48 + #("did", json.string(input.did)), 49 + #("status", json.string(input.status)), 50 + #("errorMessage", json.nullable(input.error_message, json.string)), 51 + #("eventJson", json.nullable(input.event_json, json.string)), 52 + ], 53 + ) 54 + } 55 + 56 + pub type GetRecentActivityResponse { 57 + GetRecentActivityResponse(recent_activity: List(ActivityEntry)) 58 + } 59 + 60 + pub fn get_recent_activity_response_decoder() -> decode.Decoder(GetRecentActivityResponse) { 61 + use recent_activity <- decode.field("recentActivity", decode.list(activity_entry_decoder())) 62 + decode.success(GetRecentActivityResponse(recent_activity: recent_activity)) 63 + } 64 + 65 + pub fn get_recent_activity_response_to_json(input: GetRecentActivityResponse) -> json.Json { 66 + json.object( 67 + [ 68 + #("recentActivity", json.array( 69 + from: input.recent_activity, 70 + of: activity_entry_to_json, 71 + )), 72 + ], 73 + ) 74 + } 75 + 76 + pub fn get_recent_activity(client: squall.Client, hours: Int) -> Result(Request(String), String) { 77 + squall.prepare_request( 78 + client, 79 + "query GetRecentActivity($hours: Int!) {\n recentActivity(hours: $hours) {\n id\n timestamp\n operation\n collection\n did\n status\n errorMessage\n eventJson\n }\n}", 80 + json.object([#("hours", json.int(hours))]), 81 + ) 82 + } 83 + 84 + pub fn parse_get_recent_activity_response(body: String) -> Result(GetRecentActivityResponse, String) { 85 + squall.parse_response(body, get_recent_activity_response_decoder()) 86 + }
+59
client/src/generated/queries/get_settings.gleam
··· 1 + import gleam/dynamic/decode 2 + import gleam/http/request.{type Request} 3 + import gleam/json 4 + import squall 5 + import gleam/option.{type Option} 6 + 7 + pub type Settings { 8 + Settings( 9 + id: String, 10 + domain_authority: String, 11 + oauth_client_id: Option(String), 12 + ) 13 + } 14 + 15 + pub fn settings_decoder() -> decode.Decoder(Settings) { 16 + use id <- decode.field("id", decode.string) 17 + use domain_authority <- decode.field("domainAuthority", decode.string) 18 + use oauth_client_id <- decode.field("oauthClientId", decode.optional(decode.string)) 19 + decode.success(Settings( 20 + id: id, 21 + domain_authority: domain_authority, 22 + oauth_client_id: oauth_client_id, 23 + )) 24 + } 25 + 26 + pub fn settings_to_json(input: Settings) -> json.Json { 27 + json.object( 28 + [ 29 + #("id", json.string(input.id)), 30 + #("domainAuthority", json.string(input.domain_authority)), 31 + #("oauthClientId", json.nullable(input.oauth_client_id, json.string)), 32 + ], 33 + ) 34 + } 35 + 36 + pub type GetSettingsResponse { 37 + GetSettingsResponse(settings: Settings) 38 + } 39 + 40 + pub fn get_settings_response_decoder() -> decode.Decoder(GetSettingsResponse) { 41 + use settings <- decode.field("settings", settings_decoder()) 42 + decode.success(GetSettingsResponse(settings: settings)) 43 + } 44 + 45 + pub fn get_settings_response_to_json(input: GetSettingsResponse) -> json.Json { 46 + json.object([#("settings", settings_to_json(input.settings))]) 47 + } 48 + 49 + pub fn get_settings(client: squall.Client) -> Result(Request(String), String) { 50 + squall.prepare_request( 51 + client, 52 + "query GetSettings {\n settings {\n id\n domainAuthority\n oauthClientId\n }\n}", 53 + json.object([]), 54 + ) 55 + } 56 + 57 + pub fn parse_get_settings_response(body: String) -> Result(GetSettingsResponse, String) { 58 + squall.parse_response(body, get_settings_response_decoder()) 59 + }
+54
client/src/generated/queries/get_statistics.gleam
··· 1 + import gleam/dynamic/decode 2 + import gleam/http/request.{type Request} 3 + import gleam/json 4 + import squall 5 + 6 + pub type Statistics { 7 + Statistics(record_count: Int, actor_count: Int, lexicon_count: Int) 8 + } 9 + 10 + pub fn statistics_decoder() -> decode.Decoder(Statistics) { 11 + use record_count <- decode.field("recordCount", decode.int) 12 + use actor_count <- decode.field("actorCount", decode.int) 13 + use lexicon_count <- decode.field("lexiconCount", decode.int) 14 + decode.success(Statistics( 15 + record_count: record_count, 16 + actor_count: actor_count, 17 + lexicon_count: lexicon_count, 18 + )) 19 + } 20 + 21 + pub fn statistics_to_json(input: Statistics) -> json.Json { 22 + json.object( 23 + [ 24 + #("recordCount", json.int(input.record_count)), 25 + #("actorCount", json.int(input.actor_count)), 26 + #("lexiconCount", json.int(input.lexicon_count)), 27 + ], 28 + ) 29 + } 30 + 31 + pub type GetStatisticsResponse { 32 + GetStatisticsResponse(statistics: Statistics) 33 + } 34 + 35 + pub fn get_statistics_response_decoder() -> decode.Decoder(GetStatisticsResponse) { 36 + use statistics <- decode.field("statistics", statistics_decoder()) 37 + decode.success(GetStatisticsResponse(statistics: statistics)) 38 + } 39 + 40 + pub fn get_statistics_response_to_json(input: GetStatisticsResponse) -> json.Json { 41 + json.object([#("statistics", statistics_to_json(input.statistics))]) 42 + } 43 + 44 + pub fn get_statistics(client: squall.Client) -> Result(Request(String), String) { 45 + squall.prepare_request( 46 + client, 47 + "query GetStatistics {\n statistics {\n recordCount\n actorCount\n lexiconCount\n }\n}", 48 + json.object([]), 49 + ) 50 + } 51 + 52 + pub fn parse_get_statistics_response(body: String) -> Result(GetStatisticsResponse, String) { 53 + squall.parse_response(body, get_statistics_response_decoder()) 54 + }
+29
client/src/generated/queries/reset_all.gleam
··· 1 + import gleam/dynamic/decode 2 + import gleam/http/request.{type Request} 3 + import gleam/json 4 + import squall 5 + 6 + pub type ResetAllResponse { 7 + ResetAllResponse(reset_all: Bool) 8 + } 9 + 10 + pub fn reset_all_response_decoder() -> decode.Decoder(ResetAllResponse) { 11 + use reset_all <- decode.field("resetAll", decode.bool) 12 + decode.success(ResetAllResponse(reset_all: reset_all)) 13 + } 14 + 15 + pub fn reset_all_response_to_json(input: ResetAllResponse) -> json.Json { 16 + json.object([#("resetAll", json.bool(input.reset_all))]) 17 + } 18 + 19 + pub fn reset_all(client: squall.Client, confirm: String) -> Result(Request(String), String) { 20 + squall.prepare_request( 21 + client, 22 + "mutation ResetAll($confirm: String!) {\n resetAll(confirm: $confirm)\n}", 23 + json.object([#("confirm", json.string(confirm))]), 24 + ) 25 + } 26 + 27 + pub fn parse_reset_all_response(body: String) -> Result(ResetAllResponse, String) { 28 + squall.parse_response(body, reset_all_response_decoder()) 29 + }
+29
client/src/generated/queries/trigger_backfill.gleam
··· 1 + import gleam/dynamic/decode 2 + import gleam/http/request.{type Request} 3 + import gleam/json 4 + import squall 5 + 6 + pub type TriggerBackfillResponse { 7 + TriggerBackfillResponse(trigger_backfill: Bool) 8 + } 9 + 10 + pub fn trigger_backfill_response_decoder() -> decode.Decoder(TriggerBackfillResponse) { 11 + use trigger_backfill <- decode.field("triggerBackfill", decode.bool) 12 + decode.success(TriggerBackfillResponse(trigger_backfill: trigger_backfill)) 13 + } 14 + 15 + pub fn trigger_backfill_response_to_json(input: TriggerBackfillResponse) -> json.Json { 16 + json.object([#("triggerBackfill", json.bool(input.trigger_backfill))]) 17 + } 18 + 19 + pub fn trigger_backfill(client: squall.Client) -> Result(Request(String), String) { 20 + squall.prepare_request( 21 + client, 22 + "mutation TriggerBackfill {\n triggerBackfill\n}", 23 + json.object([]), 24 + ) 25 + } 26 + 27 + pub fn parse_trigger_backfill_response(body: String) -> Result(TriggerBackfillResponse, String) { 28 + squall.parse_response(body, trigger_backfill_response_decoder()) 29 + }
+65
client/src/generated/queries/update_domain_authority.gleam
··· 1 + import gleam/dynamic/decode 2 + import gleam/http/request.{type Request} 3 + import gleam/json 4 + import squall 5 + import gleam/option.{type Option} 6 + 7 + pub type Settings { 8 + Settings( 9 + id: String, 10 + domain_authority: String, 11 + oauth_client_id: Option(String), 12 + ) 13 + } 14 + 15 + pub fn settings_decoder() -> decode.Decoder(Settings) { 16 + use id <- decode.field("id", decode.string) 17 + use domain_authority <- decode.field("domainAuthority", decode.string) 18 + use oauth_client_id <- decode.field("oauthClientId", decode.optional(decode.string)) 19 + decode.success(Settings( 20 + id: id, 21 + domain_authority: domain_authority, 22 + oauth_client_id: oauth_client_id, 23 + )) 24 + } 25 + 26 + pub fn settings_to_json(input: Settings) -> json.Json { 27 + json.object( 28 + [ 29 + #("id", json.string(input.id)), 30 + #("domainAuthority", json.string(input.domain_authority)), 31 + #("oauthClientId", json.nullable(input.oauth_client_id, json.string)), 32 + ], 33 + ) 34 + } 35 + 36 + pub type UpdateDomainAuthorityResponse { 37 + UpdateDomainAuthorityResponse(update_domain_authority: Settings) 38 + } 39 + 40 + pub fn update_domain_authority_response_decoder() -> decode.Decoder(UpdateDomainAuthorityResponse) { 41 + use update_domain_authority <- decode.field("updateDomainAuthority", settings_decoder()) 42 + decode.success(UpdateDomainAuthorityResponse( 43 + update_domain_authority: update_domain_authority, 44 + )) 45 + } 46 + 47 + pub fn update_domain_authority_response_to_json(input: UpdateDomainAuthorityResponse) -> json.Json { 48 + json.object( 49 + [ 50 + #("updateDomainAuthority", settings_to_json(input.update_domain_authority)), 51 + ], 52 + ) 53 + } 54 + 55 + pub fn update_domain_authority(client: squall.Client, domain_authority: String) -> Result(Request(String), String) { 56 + squall.prepare_request( 57 + client, 58 + "mutation UpdateDomainAuthority($domainAuthority: String!) {\n updateDomainAuthority(domainAuthority: $domainAuthority) {\n id\n domainAuthority\n oauthClientId\n }\n}", 59 + json.object([#("domainAuthority", json.string(domain_authority))]), 60 + ) 61 + } 62 + 63 + pub fn parse_update_domain_authority_response(body: String) -> Result(UpdateDomainAuthorityResponse, String) { 64 + squall.parse_response(body, update_domain_authority_response_decoder()) 65 + }
+29
client/src/generated/queries/upload_lexicons.gleam
··· 1 + import gleam/dynamic/decode 2 + import gleam/http/request.{type Request} 3 + import gleam/json 4 + import squall 5 + 6 + pub type UploadLexiconsResponse { 7 + UploadLexiconsResponse(upload_lexicons: Bool) 8 + } 9 + 10 + pub fn upload_lexicons_response_decoder() -> decode.Decoder(UploadLexiconsResponse) { 11 + use upload_lexicons <- decode.field("uploadLexicons", decode.bool) 12 + decode.success(UploadLexiconsResponse(upload_lexicons: upload_lexicons)) 13 + } 14 + 15 + pub fn upload_lexicons_response_to_json(input: UploadLexiconsResponse) -> json.Json { 16 + json.object([#("uploadLexicons", json.bool(input.upload_lexicons))]) 17 + } 18 + 19 + pub fn upload_lexicons(client: squall.Client, zip_base64: String) -> Result(Request(String), String) { 20 + squall.prepare_request( 21 + client, 22 + "mutation UploadLexicons($zipBase64: String!) {\n uploadLexicons(zipBase64: $zipBase64)\n}", 23 + json.object([#("zipBase64", json.string(zip_base64))]), 24 + ) 25 + } 26 + 27 + pub fn parse_upload_lexicons_response(body: String) -> Result(UploadLexiconsResponse, String) { 28 + squall.parse_response(body, upload_lexicons_response_decoder()) 29 + }
+45
client/src/json_formatter.ffi.mjs
··· 1 + /// JavaScript FFI for JSON formatting 2 + 3 + export function prettyPrint(jsonString) { 4 + try { 5 + const obj = JSON.parse(jsonString); 6 + return JSON.stringify(obj, null, 2); 7 + } catch (_e) { 8 + // If parsing fails, return the original string 9 + return jsonString; 10 + } 11 + } 12 + 13 + // Recursively look for string properties that are JSON and parse them 14 + function expandNestedJson(obj) { 15 + if (typeof obj === "string") { 16 + try { 17 + // Try to parse as JSON 18 + const parsed = JSON.parse(obj); 19 + return expandNestedJson(parsed); 20 + } catch (_e) { 21 + return obj; 22 + } 23 + } else if (Array.isArray(obj)) { 24 + return obj.map(expandNestedJson); 25 + } else if (obj !== null && typeof obj === "object") { 26 + const expanded = {}; 27 + for (const [key, value] of Object.entries(obj)) { 28 + expanded[key] = expandNestedJson(value); 29 + } 30 + return expanded; 31 + } 32 + return obj; 33 + } 34 + 35 + // Pretty print with nested JSON string expansion 36 + export function prettyPrintNested(jsonString) { 37 + try { 38 + const obj = JSON.parse(jsonString); 39 + const expanded = expandNestedJson(obj); 40 + return JSON.stringify(expanded, null, 2); 41 + } catch (_e) { 42 + // If parsing fails, return the original string 43 + return jsonString; 44 + } 45 + }
+9
client/src/json_formatter.gleam
··· 1 + /// JSON formatting utilities via JavaScript FFI 2 + 3 + /// Pretty-print a JSON string with indentation 4 + @external(javascript, "./json_formatter.ffi.mjs", "prettyPrint") 5 + pub fn pretty_print(json_string: String) -> String 6 + 7 + /// Pretty-print a JSON string with nested JSON strings expanded 8 + @external(javascript, "./json_formatter.ffi.mjs", "prettyPrintNested") 9 + pub fn pretty_print_nested(json_string: String) -> String
+4
client/src/navigation.gleam
··· 1 + /// Navigation helpers for external URLs 2 + 3 + @external(javascript, "./navigation_ffi.mjs", "navigateToExternal") 4 + pub fn navigate_to_external(url: String) -> Nil
+6
client/src/navigation_ffi.mjs
··· 1 + /** 2 + * Navigate to an external URL (outside the SPA) 3 + */ 4 + export function navigateToExternal(url) { 5 + globalThis.location.href = url; 6 + }
+5
client/src/number_formatter.ffi.mjs
··· 1 + /// JavaScript FFI for number formatting 2 + 3 + export function formatNumber(number) { 4 + return new Intl.NumberFormat('en-US').format(number); 5 + }
+5
client/src/number_formatter.gleam
··· 1 + /// Number formatting utilities via JavaScript FFI 2 + 3 + /// Format a number with locale-specific thousands separators 4 + @external(javascript, "./number_formatter.ffi.mjs", "formatNumber") 5 + pub fn format_number(number: Int) -> String
+110
client/src/pages/home.gleam
··· 1 + /// Home Page Component 2 + /// 3 + /// Displays dashboard with stats cards, activity chart, and recent activity 4 + import components/activity_chart 5 + import components/activity_log 6 + import components/alert 7 + import components/button 8 + import components/stats_cards 9 + import generated/queries/get_activity_buckets 10 + import generated/queries/get_settings 11 + import generated/queries/get_statistics 12 + import gleam/json 13 + import lustre/attribute 14 + import lustre/element.{type Element} 15 + import lustre/element/html 16 + import squall_cache.{type Cache} 17 + 18 + pub type Msg { 19 + ChangeTimeRange(get_activity_buckets.TimeRange) 20 + TriggerBackfill 21 + OpenGraphiQL 22 + } 23 + 24 + pub fn view( 25 + cache: Cache, 26 + time_range: get_activity_buckets.TimeRange, 27 + is_backfilling: Bool, 28 + is_admin: Bool, 29 + ) -> Element(Msg) { 30 + // Get statistics to check lexicon count 31 + let #(_cache1, stats_result) = 32 + squall_cache.lookup( 33 + cache, 34 + "GetStatistics", 35 + json.object([]), 36 + get_statistics.parse_get_statistics_response, 37 + ) 38 + 39 + // Get settings to check domain authority 40 + let #(_cache2, settings_result) = 41 + squall_cache.lookup( 42 + cache, 43 + "GetSettings", 44 + json.object([]), 45 + get_settings.parse_get_settings_response, 46 + ) 47 + 48 + // Extract domain authority and lexicon count for alerts 49 + let alerts = case stats_result, settings_result { 50 + squall_cache.Data(stats), squall_cache.Data(settings) -> 51 + render_alerts(settings.settings.domain_authority, stats.statistics.lexicon_count) 52 + _, _ -> element.none() 53 + } 54 + 55 + html.div([], [ 56 + // Configuration alerts 57 + alerts, 58 + // Action buttons 59 + html.div([attribute.class("mb-8 flex gap-3")], case is_admin { 60 + True -> [ 61 + button.button(disabled: False, on_click: OpenGraphiQL, text: "Open GraphiQL"), 62 + button.button( 63 + disabled: is_backfilling, 64 + on_click: TriggerBackfill, 65 + text: case is_backfilling { 66 + True -> "Backfilling..." 67 + False -> "Trigger Backfill" 68 + }, 69 + ), 70 + ] 71 + False -> [button.button(disabled: False, on_click: OpenGraphiQL, text: "Open GraphiQL")] 72 + }), 73 + // Stats cards component 74 + stats_cards.view(cache), 75 + // Activity chart component 76 + activity_chart.view(cache, time_range, ChangeTimeRange), 77 + // Activity log component 78 + activity_log.view(cache, 24), 79 + ]) 80 + } 81 + 82 + /// Render configuration alerts if domain authority is missing or no lexicons loaded 83 + fn render_alerts( 84 + domain_authority: String, 85 + lexicon_count: Int, 86 + ) -> Element(Msg) { 87 + let domain_alert = case domain_authority { 88 + "" -> 89 + alert.alert_with_link( 90 + alert.Warning, 91 + "No domain authority configured.", 92 + "Settings", 93 + "/settings", 94 + ) 95 + _ -> element.none() 96 + } 97 + 98 + let lexicon_alert = case lexicon_count { 99 + 0 -> 100 + alert.alert_with_link( 101 + alert.Info, 102 + "No lexicons loaded.", 103 + "Settings", 104 + "/settings", 105 + ) 106 + _ -> element.none() 107 + } 108 + 109 + html.div([], [domain_alert, lexicon_alert]) 110 + }
+355
client/src/pages/settings.gleam
··· 1 + /// Settings Page Component 2 + /// 3 + /// Displays system settings and configuration options 4 + /// 5 + /// ```graphql 6 + /// query GetSettings { 7 + /// settings { 8 + /// id 9 + /// domainAuthority 10 + /// oauthClientId 11 + /// } 12 + /// } 13 + /// ``` 14 + /// 15 + /// ```graphql 16 + /// mutation UpdateDomainAuthority($domainAuthority: String!) { 17 + /// updateDomainAuthority(domainAuthority: $domainAuthority) { 18 + /// id 19 + /// domainAuthority 20 + /// oauthClientId 21 + /// } 22 + /// } 23 + /// ``` 24 + /// 25 + /// ```graphql 26 + /// mutation UploadLexicons($zipBase64: String!) { 27 + /// uploadLexicons(zipBase64: $zipBase64) 28 + /// } 29 + /// ``` 30 + /// 31 + /// ```graphql 32 + /// mutation ResetAll($confirm: String!) { 33 + /// resetAll(confirm: $confirm) 34 + /// } 35 + /// ``` 36 + import components/alert 37 + import generated/queries/get_settings 38 + import gleam/json 39 + import gleam/option.{type Option, None, Some} 40 + import lustre/attribute 41 + import lustre/element.{type Element} 42 + import lustre/element/html 43 + import lustre/event 44 + import squall_cache.{type Cache} 45 + 46 + pub type Msg { 47 + UpdateDomainAuthorityInput(String) 48 + SubmitDomainAuthority 49 + SelectLexiconFile 50 + UploadLexicons 51 + UpdateResetConfirmation(String) 52 + SubmitReset 53 + } 54 + 55 + pub type Model { 56 + Model( 57 + domain_authority_input: String, 58 + reset_confirmation: String, 59 + selected_file: Option(String), 60 + alert: Option(#(String, String)), 61 + ) 62 + } 63 + 64 + pub fn set_alert(model: Model, kind: String, message: String) -> Model { 65 + Model(..model, alert: Some(#(kind, message))) 66 + } 67 + 68 + pub fn clear_alert(model: Model) -> Model { 69 + Model(..model, alert: None) 70 + } 71 + 72 + pub fn init() -> Model { 73 + Model( 74 + domain_authority_input: "", 75 + reset_confirmation: "", 76 + selected_file: None, 77 + alert: None, 78 + ) 79 + } 80 + 81 + pub fn view(cache: Cache, model: Model, is_admin: Bool) -> Element(Msg) { 82 + // If not admin, show access denied message 83 + case is_admin { 84 + False -> 85 + html.div([attribute.class("max-w-2xl space-y-6")], [ 86 + html.h1([attribute.class("text-2xl font-semibold text-zinc-300 mb-8")], [ 87 + element.text("Settings"), 88 + ]), 89 + html.div( 90 + [ 91 + attribute.class( 92 + "bg-zinc-800/50 rounded p-8 text-center border border-zinc-700", 93 + ), 94 + ], 95 + [ 96 + html.p([attribute.class("text-zinc-400 mb-4")], [ 97 + element.text("Access Denied"), 98 + ]), 99 + html.p([attribute.class("text-sm text-zinc-500")], [ 100 + element.text( 101 + "You must be an administrator to access the settings page.", 102 + ), 103 + ]), 104 + ], 105 + ), 106 + ]) 107 + 108 + True -> { 109 + let variables = json.object([]) 110 + 111 + let #(_cache, result) = 112 + squall_cache.lookup( 113 + cache, 114 + "GetSettings", 115 + variables, 116 + get_settings.parse_get_settings_response, 117 + ) 118 + 119 + // Check if there's a pending optimistic mutation 120 + let is_saving = has_pending_mutations(cache) 121 + 122 + html.div([attribute.class("max-w-2xl space-y-6")], [ 123 + html.h1([attribute.class("text-2xl font-semibold text-zinc-300 mb-8")], [ 124 + element.text("Settings"), 125 + ]), 126 + // Alert message 127 + case model.alert { 128 + Some(#(kind, message)) -> { 129 + let alert_kind = case kind { 130 + "success" -> alert.Success 131 + "error" -> alert.Error 132 + _ -> alert.Info 133 + } 134 + alert.alert(alert_kind, message) 135 + } 136 + None -> element.none() 137 + }, 138 + // Settings sections 139 + case result { 140 + squall_cache.Loading -> 141 + html.div([attribute.class("py-8 text-center text-zinc-600 text-sm")], [ 142 + element.text("Loading settings..."), 143 + ]) 144 + 145 + squall_cache.Failed(msg) -> 146 + html.div([attribute.class("py-8 text-center text-red-400 text-sm")], [ 147 + element.text("Error: " <> msg), 148 + ]) 149 + 150 + squall_cache.Data(data) -> 151 + html.div([attribute.class("space-y-6")], [ 152 + domain_authority_section(data.settings, model, is_saving), 153 + lexicons_section(model), 154 + oauth_section(data.settings), 155 + danger_zone_section(model), 156 + ]) 157 + }, 158 + ]) 159 + } 160 + } 161 + } 162 + 163 + /// Check if there are any pending optimistic mutations 164 + fn has_pending_mutations(cache: Cache) -> Bool { 165 + squall_cache.has_pending_mutations(cache) 166 + } 167 + 168 + fn domain_authority_section( 169 + _settings: get_settings.Settings, 170 + model: Model, 171 + is_saving: Bool, 172 + ) -> Element(Msg) { 173 + html.div([attribute.class("bg-zinc-800/50 rounded p-6")], [ 174 + html.h2([attribute.class("text-xl font-semibold text-zinc-300 mb-4")], [ 175 + element.text("Domain Authority"), 176 + ]), 177 + html.div([attribute.class("space-y-4")], [ 178 + html.div([attribute.class("mb-4")], [ 179 + html.label( 180 + [attribute.class("block text-sm text-zinc-400 mb-2")], 181 + [element.text("Domain Authority")], 182 + ), 183 + html.input([ 184 + attribute.type_("text"), 185 + attribute.class( 186 + "font-mono px-4 py-2 text-sm text-zinc-300 bg-zinc-900 border border-zinc-800 rounded focus:outline-none focus:border-zinc-700 w-full", 187 + ), 188 + attribute.placeholder("e.g. com.example"), 189 + attribute.value(model.domain_authority_input), 190 + event.on_input(UpdateDomainAuthorityInput), 191 + ]), 192 + ]), 193 + html.p([attribute.class("text-sm text-zinc-500 mb-4")], [ 194 + element.text( 195 + "The domain authority is used to determine which collections are considered \"primary\" vs \"external\" when backfilling records. For example, if the authority is \"xyz.statusphere\", then \"xyz.statusphere.status\" is treated as primary and \"app.bsky.actor.profile\" is external.", 196 + ), 197 + ]), 198 + html.div([attribute.class("flex gap-3")], [ 199 + html.button( 200 + [ 201 + attribute.class(case is_saving { 202 + True -> 203 + "font-mono px-4 py-2 text-sm text-zinc-500 bg-zinc-800 rounded cursor-not-allowed" 204 + False -> 205 + "font-mono px-4 py-2 text-sm text-zinc-300 bg-zinc-800 hover:bg-zinc-700 rounded transition-colors cursor-pointer" 206 + }), 207 + attribute.disabled(is_saving), 208 + event.on_click(SubmitDomainAuthority), 209 + ], 210 + [element.text(case is_saving { 211 + True -> "Saving..." 212 + False -> "Save" 213 + })], 214 + ), 215 + ]), 216 + ]), 217 + ]) 218 + } 219 + 220 + fn oauth_section(settings: get_settings.Settings) -> Element(Msg) { 221 + html.div([attribute.class("bg-zinc-800/50 rounded p-6")], [ 222 + html.h2([attribute.class("text-xl font-semibold text-zinc-300 mb-4")], [ 223 + element.text("OAuth Configuration"), 224 + ]), 225 + case settings.oauth_client_id { 226 + Some(client_id) -> 227 + html.div([attribute.class("space-y-3")], [ 228 + html.div([attribute.class("flex items-center gap-2")], [ 229 + html.div([attribute.class("w-2 h-2 bg-green-500 rounded-full")], []), 230 + html.p([attribute.class("text-sm text-zinc-300")], [ 231 + element.text("OAuth client registered"), 232 + ]), 233 + ]), 234 + html.div([attribute.class("bg-zinc-900/50 rounded p-3")], [ 235 + html.p([attribute.class("text-xs text-zinc-500 mb-1")], [ 236 + element.text("Client ID:"), 237 + ]), 238 + html.p([attribute.class("text-sm text-zinc-300 font-mono")], [ 239 + element.text(client_id), 240 + ]), 241 + ]), 242 + html.p([attribute.class("text-sm text-zinc-500")], [ 243 + element.text( 244 + "OAuth client credentials are stored in the database. Use \"Reset Everything\" to clear and trigger re-registration.", 245 + ), 246 + ]), 247 + ]) 248 + None -> 249 + html.div([attribute.class("space-y-3")], [ 250 + html.div([attribute.class("flex items-center gap-2")], [ 251 + html.div([attribute.class("w-2 h-2 bg-zinc-500 rounded-full")], []), 252 + html.p([attribute.class("text-sm text-zinc-400")], [ 253 + element.text("OAuth client not registered"), 254 + ]), 255 + ]), 256 + html.p([attribute.class("text-sm text-zinc-500")], [ 257 + element.text( 258 + "Set ENABLE_OAUTH_AUTO_REGISTER=true in your .env file to enable automatic OAuth client registration. The server will automatically register with your configured AIP server on startup.", 259 + ), 260 + ]), 261 + ]) 262 + }, 263 + ]) 264 + } 265 + 266 + fn lexicons_section(_model: Model) -> Element(Msg) { 267 + html.div([attribute.class("bg-zinc-800/50 rounded p-6")], [ 268 + html.h2([attribute.class("text-xl font-semibold text-zinc-300 mb-4")], [ 269 + element.text("Lexicons"), 270 + ]), 271 + html.div([attribute.class("space-y-4")], [ 272 + html.div([attribute.class("mb-4")], [ 273 + html.label( 274 + [attribute.class("block text-sm text-zinc-400 mb-2")], 275 + [element.text("Upload Lexicons (ZIP)")], 276 + ), 277 + html.input([ 278 + attribute.type_("file"), 279 + attribute.accept([".zip"]), 280 + attribute.class( 281 + "font-mono px-4 py-2 text-sm text-zinc-300 bg-zinc-900 border border-zinc-800 rounded focus:outline-none focus:border-zinc-700 w-full", 282 + ), 283 + attribute.id("lexicon-file-input"), 284 + event.on_input(fn(_) { SelectLexiconFile }), 285 + ]), 286 + ]), 287 + html.p([attribute.class("text-sm text-zinc-500 mb-4")], [ 288 + element.text( 289 + "Upload a ZIP file containing lexicon JSON files. The ZIP file will be extracted and all .json files will be imported into the database. This replaces the need to manually place lexicons in the priv/lexicons directory.", 290 + ), 291 + ]), 292 + html.div([attribute.class("flex gap-3")], [ 293 + html.button( 294 + [ 295 + attribute.class( 296 + "font-mono px-4 py-2 text-sm text-zinc-300 bg-zinc-800 hover:bg-zinc-700 rounded transition-colors cursor-pointer", 297 + ), 298 + event.on_click(UploadLexicons), 299 + ], 300 + [element.text("Upload")], 301 + ), 302 + ]), 303 + ]), 304 + ]) 305 + } 306 + 307 + fn danger_zone_section(model: Model) -> Element(Msg) { 308 + html.div([attribute.class("bg-zinc-800/50 rounded p-6")], [ 309 + html.h2([attribute.class("text-xl font-semibold text-zinc-300 mb-4")], [ 310 + element.text("Danger Zone"), 311 + ]), 312 + html.p([attribute.class("text-sm text-zinc-400 mb-4")], [ 313 + element.text("This will clear all indexed data:"), 314 + ]), 315 + html.ul([attribute.class("text-sm text-zinc-400 mb-4 ml-4 list-disc")], [ 316 + html.li([], [element.text("Domain authority configuration")]), 317 + html.li([], [element.text("OAuth client credentials")]), 318 + html.li([], [element.text("All lexicon definitions")]), 319 + html.li([], [element.text("All indexed records")]), 320 + html.li([], [element.text("All actors")]), 321 + ]), 322 + html.p([attribute.class("text-sm text-zinc-400 mb-4")], [ 323 + element.text("Records can be re-indexed via backfill."), 324 + ]), 325 + html.div([attribute.class("space-y-4")], [ 326 + html.div([attribute.class("mb-4")], [ 327 + html.label( 328 + [attribute.class("block text-sm text-zinc-400 mb-2")], 329 + [element.text("Type RESET to confirm")], 330 + ), 331 + html.input([ 332 + attribute.type_("text"), 333 + attribute.class( 334 + "font-mono px-4 py-2 text-sm text-zinc-300 bg-zinc-900 border border-zinc-800 rounded focus:outline-none focus:border-zinc-700 w-full", 335 + ), 336 + attribute.placeholder("RESET"), 337 + attribute.value(model.reset_confirmation), 338 + event.on_input(UpdateResetConfirmation), 339 + ]), 340 + ]), 341 + html.div([attribute.class("flex gap-3")], [ 342 + html.button( 343 + [ 344 + attribute.class( 345 + "font-mono px-4 py-2 text-sm text-red-400 border border-red-900 hover:bg-red-900/30 rounded transition-colors cursor-pointer", 346 + ), 347 + attribute.disabled(model.reset_confirmation != "RESET"), 348 + event.on_click(SubmitReset), 349 + ], 350 + [element.text("Reset Everything")], 351 + ), 352 + ]), 353 + ]), 354 + ]) 355 + }
+11
client/src/quickslice_client.ffi.mjs
··· 1 + /// JavaScript FFI for quickslice_client 2 + 3 + /** 4 + * Set a timeout to call a callback after a delay 5 + * @param {function} callback - The function to call 6 + * @param {number} milliseconds - The delay in milliseconds 7 + * @returns {void} 8 + */ 9 + export function doSetTimeout(callback, milliseconds) { 10 + setTimeout(callback, milliseconds); 11 + }
+835
client/src/quickslice_client.gleam
··· 1 + /// ```graphql 2 + /// mutation TriggerBackfill { 3 + /// triggerBackfill 4 + /// } 5 + /// ``` 6 + /// ```graphql 7 + /// query GetCurrentSession { 8 + /// currentSession { 9 + /// did 10 + /// handle 11 + /// isAdmin 12 + /// } 13 + /// } 14 + /// ``` 15 + import components/layout 16 + import file_upload 17 + import generated/queries 18 + import navigation 19 + import generated/queries/get_activity_buckets.{ONEDAY} 20 + import generated/queries/get_current_session 21 + import generated/queries/get_recent_activity 22 + import generated/queries/get_settings 23 + import generated/queries/get_statistics 24 + import generated/queries/reset_all 25 + import generated/queries/trigger_backfill 26 + import generated/queries/update_domain_authority 27 + import generated/queries/upload_lexicons 28 + import gleam/io 29 + import gleam/json.{type Json} 30 + import gleam/list 31 + import gleam/option.{None} 32 + import gleam/uri 33 + import lustre 34 + import lustre/attribute 35 + import lustre/effect.{type Effect} 36 + import lustre/element.{type Element} 37 + import lustre/element/html 38 + import modem 39 + import pages/home 40 + import pages/settings 41 + import squall_cache 42 + import squall/registry 43 + 44 + pub fn main() { 45 + let app = lustre.application(init, update, view) 46 + let assert Ok(_) = lustre.start(app, "#app", Nil) 47 + } 48 + 49 + // MODEL 50 + 51 + pub type Route { 52 + Home 53 + Settings 54 + Upload 55 + } 56 + 57 + pub type AuthState { 58 + NotAuthenticated 59 + Authenticated(did: String, handle: String, is_admin: Bool) 60 + } 61 + 62 + pub type Model { 63 + Model( 64 + cache: squall_cache.Cache, 65 + registry: registry.Registry, 66 + route: Route, 67 + time_range: get_activity_buckets.TimeRange, 68 + settings_page_model: settings.Model, 69 + is_backfilling: Bool, 70 + auth_state: AuthState, 71 + ) 72 + } 73 + 74 + fn init(_flags) -> #(Model, Effect(Msg)) { 75 + // Create cache 76 + let cache = squall_cache.new("http://localhost:8000/admin/graphql") 77 + 78 + // Initialize registry with all extracted queries 79 + let reg = queries.init_registry() 80 + 81 + // Parse the initial route from the current URL 82 + let initial_route = case modem.initial_uri() { 83 + Ok(uri) -> parse_route(uri) 84 + Error(_) -> Home 85 + } 86 + 87 + // Fetch current session first (needed for all routes) 88 + let #(cache_with_session, _) = 89 + squall_cache.lookup( 90 + cache, 91 + "GetCurrentSession", 92 + json.object([]), 93 + get_current_session.parse_get_current_session_response, 94 + ) 95 + 96 + // Trigger initial data fetches for the route 97 + let #(initial_cache, data_effects) = case initial_route { 98 + Home -> { 99 + // GetStatistics query 100 + let #(cache1, _) = 101 + squall_cache.lookup( 102 + cache_with_session, 103 + "GetStatistics", 104 + json.object([]), 105 + get_statistics.parse_get_statistics_response, 106 + ) 107 + 108 + // GetSettings query (for configuration alerts) 109 + let #(cache2, _) = 110 + squall_cache.lookup( 111 + cache1, 112 + "GetSettings", 113 + json.object([]), 114 + get_settings.parse_get_settings_response, 115 + ) 116 + 117 + // GetActivityBuckets query 118 + let #(cache3, _) = 119 + squall_cache.lookup( 120 + cache2, 121 + "GetActivityBuckets", 122 + json.object([#("range", json.string("ONE_DAY"))]), 123 + get_activity_buckets.parse_get_activity_buckets_response, 124 + ) 125 + 126 + // GetRecentActivity query 127 + let #(cache4, _) = 128 + squall_cache.lookup( 129 + cache3, 130 + "GetRecentActivity", 131 + json.object([#("hours", json.int(24))]), 132 + get_recent_activity.parse_get_recent_activity_response, 133 + ) 134 + 135 + // Process all pending fetches 136 + let #(final_cache, fx) = 137 + squall_cache.process_pending(cache4, reg, HandleQueryResponse, fn() { 138 + 0 139 + }) 140 + #(final_cache, fx) 141 + } 142 + Settings -> { 143 + // GetSettings query 144 + let #(cache1, _) = 145 + squall_cache.lookup( 146 + cache_with_session, 147 + "GetSettings", 148 + json.object([]), 149 + get_settings.parse_get_settings_response, 150 + ) 151 + 152 + // Process pending fetches 153 + let #(final_cache, fx) = 154 + squall_cache.process_pending(cache1, reg, HandleQueryResponse, fn() { 155 + 0 156 + }) 157 + #(final_cache, fx) 158 + } 159 + _ -> #(cache_with_session, []) 160 + } 161 + 162 + // Combine modem effect with data fetching effects 163 + let modem_effect = modem.init(on_url_change) 164 + let combined_effects = effect.batch([modem_effect, ..data_effects]) 165 + 166 + #( 167 + Model( 168 + cache: initial_cache, 169 + registry: reg, 170 + route: initial_route, 171 + time_range: ONEDAY, 172 + settings_page_model: settings.init(), 173 + is_backfilling: False, 174 + auth_state: NotAuthenticated, 175 + ), 176 + combined_effects, 177 + ) 178 + } 179 + 180 + // UPDATE 181 + 182 + pub type Msg { 183 + HandleQueryResponse(String, Json, Result(String, String)) 184 + HandleOptimisticMutationSuccess(String, String) 185 + HandleOptimisticMutationFailure(String, String) 186 + OnRouteChange(Route) 187 + HomePageMsg(home.Msg) 188 + SettingsPageMsg(settings.Msg) 189 + FileRead(Result(String, String)) 190 + } 191 + 192 + fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { 193 + case msg { 194 + HandleOptimisticMutationSuccess(mutation_id, response_body) -> { 195 + // Mutation succeeded - commit the optimistic update 196 + let cache_after_commit = squall_cache.commit_optimistic(model.cache, mutation_id, response_body) 197 + 198 + let new_settings_model = 199 + settings.set_alert( 200 + model.settings_page_model, 201 + "success", 202 + "Domain authority updated successfully", 203 + ) 204 + 205 + #(Model(..model, cache: cache_after_commit, settings_page_model: new_settings_model), effect.none()) 206 + } 207 + 208 + HandleOptimisticMutationFailure(mutation_id, error_message) -> { 209 + // Mutation failed - rollback the optimistic update 210 + let cache_after_rollback = squall_cache.rollback_optimistic(model.cache, mutation_id) 211 + 212 + // Get the actual saved value from cache to reset the input field 213 + let saved_domain_authority = case squall_cache.lookup( 214 + cache_after_rollback, 215 + "GetSettings", 216 + json.object([]), 217 + get_settings.parse_get_settings_response, 218 + ) { 219 + #(_, squall_cache.Data(data)) -> data.settings.domain_authority 220 + _ -> model.settings_page_model.domain_authority_input 221 + } 222 + 223 + let new_settings_model = 224 + settings.Model( 225 + ..model.settings_page_model, 226 + domain_authority_input: saved_domain_authority, 227 + alert: option.Some(#("error", "Error: " <> error_message)), 228 + ) 229 + 230 + #(Model(..model, cache: cache_after_rollback, settings_page_model: new_settings_model), effect.none()) 231 + } 232 + 233 + HandleQueryResponse(query_name, variables, Ok(response_body)) -> { 234 + // Store response in cache 235 + let cache_with_data = 236 + squall_cache.store_query( 237 + model.cache, 238 + query_name, 239 + variables, 240 + response_body, 241 + 0, 242 + ) 243 + 244 + // Process any new pending fetches 245 + let #(final_cache, effects) = 246 + squall_cache.process_pending( 247 + cache_with_data, 248 + model.registry, 249 + HandleQueryResponse, 250 + fn() { 0 }, 251 + ) 252 + 253 + // Reset is_backfilling flag for TriggerBackfill mutation 254 + let updated_is_backfilling = case query_name { 255 + "TriggerBackfill" -> False 256 + _ -> model.is_backfilling 257 + } 258 + 259 + // Update auth state when GetCurrentSession response arrives 260 + let new_auth_state = case query_name { 261 + "GetCurrentSession" -> { 262 + case get_current_session.parse_get_current_session_response(response_body) { 263 + Ok(data) -> { 264 + case data.current_session { 265 + option.Some(session) -> 266 + Authenticated( 267 + did: session.did, 268 + handle: session.handle, 269 + is_admin: session.is_admin, 270 + ) 271 + option.None -> NotAuthenticated 272 + } 273 + } 274 + Error(_) -> NotAuthenticated 275 + } 276 + } 277 + _ -> model.auth_state 278 + } 279 + 280 + // Show success message for mutations and populate settings data 281 + let new_settings_model = case query_name { 282 + "UpdateDomainAuthority" -> 283 + settings.set_alert( 284 + model.settings_page_model, 285 + "success", 286 + "Domain authority updated successfully", 287 + ) 288 + "UploadLexicons" -> { 289 + // Clear the file input so the same file can be uploaded again 290 + file_upload.clear_file_input("lexicon-file-input") 291 + settings.set_alert( 292 + model.settings_page_model, 293 + "success", 294 + "Lexicons uploaded successfully", 295 + ) 296 + } 297 + "ResetAll" -> { 298 + // Clear the domain authority input when reset completes 299 + let cleared_model = settings.Model( 300 + ..model.settings_page_model, 301 + domain_authority_input: "", 302 + ) 303 + settings.set_alert( 304 + cleared_model, 305 + "success", 306 + "All data has been reset", 307 + ) 308 + } 309 + "GetSettings" -> { 310 + // Populate the input field with the loaded domain authority 311 + case get_settings.parse_get_settings_response(response_body) { 312 + Ok(data) -> 313 + settings.Model( 314 + ..model.settings_page_model, 315 + domain_authority_input: data.settings.domain_authority, 316 + ) 317 + Error(_) -> model.settings_page_model 318 + } 319 + } 320 + _ -> model.settings_page_model 321 + } 322 + 323 + // Invalidate queries after mutations that change data 324 + let #(cache_after_mutation, mutation_effects) = case query_name { 325 + "ResetAll" -> { 326 + // Invalidate home page queries so they refetch when navigating home 327 + let cache1 = 328 + squall_cache.invalidate( 329 + final_cache, 330 + "GetStatistics", 331 + json.object([]), 332 + ) 333 + let cache2 = 334 + squall_cache.invalidate( 335 + cache1, 336 + "GetActivityBuckets", 337 + json.object([ 338 + #("range", json.string(get_activity_buckets.time_range_to_string(model.time_range))), 339 + ]), 340 + ) 341 + let cache3 = 342 + squall_cache.invalidate( 343 + cache2, 344 + "GetRecentActivity", 345 + json.object([#("hours", json.int(24))]), 346 + ) 347 + 348 + // Refetch settings to keep the settings page working 349 + let #(cache_with_settings, _) = 350 + squall_cache.lookup( 351 + cache3, 352 + "GetSettings", 353 + json.object([]), 354 + get_settings.parse_get_settings_response, 355 + ) 356 + 357 + let #(final_cache_reset, refetch_effects) = 358 + squall_cache.process_pending( 359 + cache_with_settings, 360 + model.registry, 361 + HandleQueryResponse, 362 + fn() { 0 }, 363 + ) 364 + 365 + #(final_cache_reset, refetch_effects) 366 + } 367 + "UploadLexicons" -> { 368 + // Invalidate statistics since lexicon count changed 369 + let cache_invalidated = 370 + squall_cache.invalidate( 371 + final_cache, 372 + "GetStatistics", 373 + json.object([]), 374 + ) 375 + #(cache_invalidated, []) 376 + } 377 + _ -> #(final_cache, []) 378 + } 379 + 380 + // Check if we need to redirect after session loads 381 + let redirect_effect = case query_name { 382 + "GetCurrentSession" -> { 383 + // If we're on settings route but not admin, redirect to home 384 + case model.route, new_auth_state { 385 + Settings, NotAuthenticated -> [modem.push("/", option.None, option.None)] 386 + Settings, Authenticated(_, _, False) -> [modem.push("/", option.None, option.None)] 387 + _, _ -> [] 388 + } 389 + } 390 + _ -> [] 391 + } 392 + 393 + #( 394 + Model(..model, cache: cache_after_mutation, settings_page_model: new_settings_model, is_backfilling: updated_is_backfilling, auth_state: new_auth_state), 395 + effect.batch([effects, mutation_effects, redirect_effect] |> list.flatten), 396 + ) 397 + } 398 + 399 + HandleQueryResponse(query_name, _variables, Error(err)) -> { 400 + // Reset is_backfilling flag for TriggerBackfill mutation 401 + let updated_is_backfilling = case query_name { 402 + "TriggerBackfill" -> False 403 + _ -> model.is_backfilling 404 + } 405 + 406 + // Show error message for mutations 407 + let new_settings_model = case query_name { 408 + "UpdateDomainAuthority" | "UploadLexicons" | "ResetAll" | "TriggerBackfill" -> 409 + settings.set_alert( 410 + model.settings_page_model, 411 + "error", 412 + "Error: " <> err, 413 + ) 414 + _ -> model.settings_page_model 415 + } 416 + 417 + #(Model(..model, settings_page_model: new_settings_model, is_backfilling: updated_is_backfilling), effect.none()) 418 + } 419 + 420 + OnRouteChange(route) -> { 421 + // Clear any alerts when navigating away from settings 422 + let cleared_settings_model = case model.route { 423 + Settings -> settings.clear_alert(model.settings_page_model) 424 + _ -> model.settings_page_model 425 + } 426 + 427 + // When route changes, fetch data for that route 428 + case route { 429 + Home -> { 430 + // Fetch home page data 431 + // GetStatistics query 432 + let #(cache1, _) = 433 + squall_cache.lookup( 434 + model.cache, 435 + "GetStatistics", 436 + json.object([]), 437 + get_statistics.parse_get_statistics_response, 438 + ) 439 + 440 + // GetSettings query (for configuration alerts) 441 + let #(cache2, _) = 442 + squall_cache.lookup( 443 + cache1, 444 + "GetSettings", 445 + json.object([]), 446 + get_settings.parse_get_settings_response, 447 + ) 448 + 449 + // GetActivityBuckets query 450 + let #(cache3, _) = 451 + squall_cache.lookup( 452 + cache2, 453 + "GetActivityBuckets", 454 + json.object([ 455 + #("range", json.string(get_activity_buckets.time_range_to_string(model.time_range))), 456 + ]), 457 + get_activity_buckets.parse_get_activity_buckets_response, 458 + ) 459 + 460 + // GetRecentActivity query 461 + let #(cache4, _) = 462 + squall_cache.lookup( 463 + cache3, 464 + "GetRecentActivity", 465 + json.object([#("hours", json.int(24))]), 466 + get_recent_activity.parse_get_recent_activity_response, 467 + ) 468 + 469 + // Process all pending fetches 470 + let #(final_cache, effects) = 471 + squall_cache.process_pending( 472 + cache4, 473 + model.registry, 474 + HandleQueryResponse, 475 + fn() { 0 }, 476 + ) 477 + 478 + #(Model(..model, route: route, cache: final_cache, settings_page_model: cleared_settings_model), effect.batch(effects)) 479 + } 480 + Settings -> { 481 + // Check if user is admin 482 + let is_admin = case model.auth_state { 483 + Authenticated(_, _, admin) -> admin 484 + NotAuthenticated -> False 485 + } 486 + 487 + case is_admin { 488 + False -> { 489 + // Non-admin trying to access settings - redirect to home 490 + #(model, modem.push("/", option.None, option.None)) 491 + } 492 + True -> { 493 + // Fetch settings data 494 + let #(cache_with_lookup, _) = 495 + squall_cache.lookup( 496 + model.cache, 497 + "GetSettings", 498 + json.object([]), 499 + get_settings.parse_get_settings_response, 500 + ) 501 + 502 + let #(final_cache, effects) = 503 + squall_cache.process_pending( 504 + cache_with_lookup, 505 + model.registry, 506 + HandleQueryResponse, 507 + fn() { 0 }, 508 + ) 509 + 510 + #(Model(..model, route: route, cache: final_cache, settings_page_model: cleared_settings_model), effect.batch(effects)) 511 + } 512 + } 513 + } 514 + _ -> #(Model(..model, route: route, settings_page_model: cleared_settings_model), effect.none()) 515 + } 516 + } 517 + 518 + HomePageMsg(home_msg) -> { 519 + case home_msg { 520 + home.ChangeTimeRange(new_range) -> { 521 + // Update time range and fetch new activity data 522 + let variables = 523 + json.object([ 524 + #("range", json.string(get_activity_buckets.time_range_to_string(new_range))), 525 + ]) 526 + 527 + let #(cache_with_lookup, _) = 528 + squall_cache.lookup( 529 + model.cache, 530 + "GetActivityBuckets", 531 + variables, 532 + get_activity_buckets.parse_get_activity_buckets_response, 533 + ) 534 + 535 + let #(final_cache, effects) = 536 + squall_cache.process_pending( 537 + cache_with_lookup, 538 + model.registry, 539 + HandleQueryResponse, 540 + fn() { 0 }, 541 + ) 542 + 543 + #(Model(..model, cache: final_cache, time_range: new_range), effect.batch(effects)) 544 + } 545 + 546 + home.OpenGraphiQL -> { 547 + // Navigate to external GraphiQL page 548 + navigation.navigate_to_external("/graphiql") 549 + #(model, effect.none()) 550 + } 551 + 552 + home.TriggerBackfill -> { 553 + // Trigger backfill mutation 554 + let variables = json.object([]) 555 + 556 + // Invalidate any cached mutation result to ensure a fresh request 557 + let cache_invalidated = squall_cache.invalidate(model.cache, "TriggerBackfill", variables) 558 + 559 + let #(cache_with_lookup, _) = 560 + squall_cache.lookup( 561 + cache_invalidated, 562 + "TriggerBackfill", 563 + variables, 564 + trigger_backfill.parse_trigger_backfill_response, 565 + ) 566 + 567 + let #(final_cache, effects) = 568 + squall_cache.process_pending( 569 + cache_with_lookup, 570 + model.registry, 571 + HandleQueryResponse, 572 + fn() { 0 }, 573 + ) 574 + 575 + // Set is_backfilling to True while request is pending 576 + #( 577 + Model(..model, cache: final_cache, is_backfilling: True), 578 + effect.batch(effects), 579 + ) 580 + } 581 + } 582 + } 583 + 584 + SettingsPageMsg(settings_msg) -> { 585 + case settings_msg { 586 + settings.UpdateDomainAuthorityInput(value) -> { 587 + // Clear alert when user starts typing 588 + let new_settings_model = 589 + settings.Model( 590 + ..model.settings_page_model, 591 + domain_authority_input: value, 592 + alert: None, 593 + ) 594 + #(Model(..model, settings_page_model: new_settings_model), effect.none()) 595 + } 596 + 597 + settings.SubmitDomainAuthority -> { 598 + // Clear any existing alert 599 + let cleared_settings_model = 600 + settings.Model(..model.settings_page_model, alert: None) 601 + 602 + // Execute optimistic mutation 603 + let variables = 604 + json.object([ 605 + #("domainAuthority", json.string(model.settings_page_model.domain_authority_input)), 606 + ]) 607 + 608 + // Create optimistic entity - get current oauthClientId from cache 609 + let current_oauth_client_id = case squall_cache.lookup( 610 + model.cache, 611 + "GetSettings", 612 + json.object([]), 613 + get_settings.parse_get_settings_response, 614 + ) { 615 + #(_, squall_cache.Data(data)) -> data.settings.oauth_client_id 616 + _ -> None 617 + } 618 + 619 + let optimistic_entity = 620 + json.object([ 621 + #("id", json.string("Settings:singleton")), 622 + #("domainAuthority", json.string(model.settings_page_model.domain_authority_input)), 623 + #("oauthClientId", json.nullable(current_oauth_client_id, json.string)), 624 + ]) 625 + 626 + let #(updated_cache, _mutation_id, mutation_effect) = 627 + squall_cache.execute_optimistic_mutation( 628 + model.cache, 629 + model.registry, 630 + "UpdateDomainAuthority", 631 + variables, 632 + "Settings:singleton", 633 + fn(_current) { optimistic_entity }, 634 + update_domain_authority.parse_update_domain_authority_response, 635 + fn(mutation_id, result, response_body) { 636 + case result { 637 + Ok(_) -> HandleOptimisticMutationSuccess(mutation_id, response_body) 638 + Error(err) -> HandleOptimisticMutationFailure(mutation_id, err) 639 + } 640 + }, 641 + ) 642 + 643 + #(Model(..model, cache: updated_cache, settings_page_model: cleared_settings_model), mutation_effect) 644 + } 645 + 646 + settings.SelectLexiconFile -> { 647 + // File selection is handled by browser - we'll read the file on upload 648 + #(model, effect.none()) 649 + } 650 + 651 + settings.UploadLexicons -> { 652 + // Read the file and convert to base64 653 + io.println("[UploadLexicons] Button clicked, creating file effect") 654 + let file_effect = 655 + effect.from(fn(dispatch) { 656 + io.println("[UploadLexicons] Effect running, calling read_file_as_base64") 657 + file_upload.read_file_as_base64("lexicon-file-input", fn(result) { 658 + io.println("[UploadLexicons] Callback received result") 659 + dispatch(FileRead(result)) 660 + }) 661 + }) 662 + #(model, file_effect) 663 + } 664 + 665 + settings.UpdateResetConfirmation(value) -> { 666 + // Clear alert when user starts typing 667 + let new_settings_model = 668 + settings.Model( 669 + ..model.settings_page_model, 670 + reset_confirmation: value, 671 + alert: None, 672 + ) 673 + #(Model(..model, settings_page_model: new_settings_model), effect.none()) 674 + } 675 + 676 + settings.SubmitReset -> { 677 + // Execute ResetAll mutation 678 + let variables = 679 + json.object([ 680 + #("confirm", json.string(model.settings_page_model.reset_confirmation)), 681 + ]) 682 + 683 + // Invalidate any cached mutation result to ensure a fresh request 684 + let cache_invalidated = squall_cache.invalidate(model.cache, "ResetAll", variables) 685 + 686 + let #(cache_with_lookup, _) = 687 + squall_cache.lookup( 688 + cache_invalidated, 689 + "ResetAll", 690 + variables, 691 + reset_all.parse_reset_all_response, 692 + ) 693 + 694 + let #(final_cache, effects) = 695 + squall_cache.process_pending( 696 + cache_with_lookup, 697 + model.registry, 698 + HandleQueryResponse, 699 + fn() { 0 }, 700 + ) 701 + 702 + // Clear the confirmation field and alert after submission 703 + let new_settings_model = 704 + settings.Model( 705 + ..model.settings_page_model, 706 + reset_confirmation: "", 707 + alert: None, 708 + ) 709 + 710 + #( 711 + Model(..model, cache: final_cache, settings_page_model: new_settings_model), 712 + effect.batch(effects), 713 + ) 714 + } 715 + } 716 + } 717 + 718 + FileRead(Ok(base64_content)) -> { 719 + // File was successfully read, now upload it 720 + io.println("[FileRead] Successfully read file, uploading...") 721 + let variables = 722 + json.object([#("zipBase64", json.string(base64_content))]) 723 + 724 + // Invalidate any cached mutation result to ensure a fresh request 725 + let cache_invalidated = squall_cache.invalidate(model.cache, "UploadLexicons", variables) 726 + 727 + let #(cache_with_lookup, _) = 728 + squall_cache.lookup( 729 + cache_invalidated, 730 + "UploadLexicons", 731 + variables, 732 + upload_lexicons.parse_upload_lexicons_response, 733 + ) 734 + 735 + let #(final_cache, effects) = 736 + squall_cache.process_pending( 737 + cache_with_lookup, 738 + model.registry, 739 + HandleQueryResponse, 740 + fn() { 0 }, 741 + ) 742 + 743 + // Clear the selected file 744 + let new_settings_model = 745 + settings.Model(..model.settings_page_model, selected_file: None) 746 + 747 + #( 748 + Model(..model, cache: final_cache, settings_page_model: new_settings_model), 749 + effect.batch(effects), 750 + ) 751 + } 752 + 753 + FileRead(Error(err)) -> { 754 + // Handle file read error 755 + io.println("[FileRead] Error reading file: " <> err) 756 + let new_settings_model = 757 + settings.set_alert(model.settings_page_model, "error", err) 758 + #(Model(..model, settings_page_model: new_settings_model), effect.none()) 759 + } 760 + } 761 + } 762 + 763 + // VIEW 764 + 765 + fn view(model: Model) -> Element(Msg) { 766 + // Convert AuthState to Option for layout 767 + let auth_info = case model.auth_state { 768 + NotAuthenticated -> None 769 + Authenticated(_did, handle, is_admin) -> option.Some(#(handle, is_admin)) 770 + } 771 + 772 + html.div( 773 + [attribute.class("bg-zinc-950 text-zinc-300 font-mono min-h-screen")], 774 + [ 775 + html.div([attribute.class("max-w-4xl mx-auto px-6 py-12")], [ 776 + layout.header(auth_info), 777 + case model.route { 778 + Home -> view_home(model) 779 + Settings -> view_settings(model) 780 + Upload -> view_upload(model) 781 + }, 782 + ]), 783 + ], 784 + ) 785 + } 786 + 787 + fn view_home(model: Model) -> Element(Msg) { 788 + let is_admin = case model.auth_state { 789 + Authenticated(_, _, is_admin) -> is_admin 790 + NotAuthenticated -> False 791 + } 792 + 793 + element.map( 794 + home.view(model.cache, model.time_range, model.is_backfilling, is_admin), 795 + HomePageMsg, 796 + ) 797 + } 798 + 799 + fn view_settings(model: Model) -> Element(Msg) { 800 + let is_admin = case model.auth_state { 801 + Authenticated(_, _, is_admin) -> is_admin 802 + NotAuthenticated -> False 803 + } 804 + 805 + element.map( 806 + settings.view(model.cache, model.settings_page_model, is_admin), 807 + SettingsPageMsg, 808 + ) 809 + } 810 + 811 + fn view_upload(_model: Model) -> Element(Msg) { 812 + html.div([], [ 813 + html.h1([attribute.class("text-xl font-bold text-zinc-100 mb-4")], [ 814 + html.text("Upload"), 815 + ]), 816 + html.p([attribute.class("text-zinc-400")], [ 817 + html.text("Upload and manage data"), 818 + ]), 819 + ]) 820 + } 821 + 822 + // ROUTING 823 + 824 + fn on_url_change(uri: uri.Uri) -> Msg { 825 + OnRouteChange(parse_route(uri)) 826 + } 827 + 828 + fn parse_route(uri: uri.Uri) -> Route { 829 + case uri.path { 830 + "/" -> Home 831 + "/settings" -> Settings 832 + "/upload" -> Upload 833 + _ -> Home 834 + } 835 + }
+13
client/test/cache_example_test.gleam
··· 1 + import gleeunit 2 + 3 + pub fn main() -> Nil { 4 + gleeunit.main() 5 + } 6 + 7 + // gleeunit test functions end in `_test` 8 + pub fn hello_world_test() { 9 + let name = "Joe" 10 + let greeting = "Hello, " <> name <> "!" 11 + 12 + assert greeting == "Hello, Joe!" 13 + }
-1
server/gleam.toml
··· 27 27 gleam_hackney = ">= 1.0.0 and < 2.0.0" 28 28 sqlight = ">= 1.0.0 and < 2.0.0" 29 29 gleam_time = ">= 1.4.0 and < 2.0.0" 30 - lustre = ">= 5.0.0 and < 6.0.0" 31 30 simplifile = ">= 2.0.0 and < 3.0.0" 32 31 argv = ">= 1.0.0 and < 2.0.0" 33 32 jose = ">= 1.11.10 and < 2.0.0"
+1 -3
server/manifest.toml
··· 34 34 { name = "lexicon", version = "0.1.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], source = "local", path = "../lexicon" }, 35 35 { name = "lexicon_graphql", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib", "swell"], source = "local", path = "../lexicon_graphql" }, 36 36 { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, 37 - { name = "lustre", version = "5.4.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib", "houdini"], otp_app = "lustre", source = "hex", outer_checksum = "40E097BABCE65FB7C460C073078611F7F5802EB07E1A9BFB5C229F71B60F8E50" }, 38 37 { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, 39 38 { name = "metrics", version = "1.0.1", build_tools = ["rebar3"], requirements = [], otp_app = "metrics", source = "hex", outer_checksum = "69B09ADDDC4F74A40716AE54D140F93BEB0FB8978D8636EADED0C31B6F099F16" }, 40 39 { name = "mimerl", version = "1.4.0", build_tools = ["rebar3"], requirements = [], otp_app = "mimerl", source = "hex", outer_checksum = "13AF15F9F68C65884ECCA3A3891D50A7B57D82152792F3E19D88650AA126B144" }, ··· 72 71 lexicon = { path = "../lexicon" } 73 72 lexicon_graphql = { path = "../lexicon_graphql" } 74 73 logging = { version = ">= 1.3.0 and < 2.0.0" } 75 - lustre = { version = ">= 5.0.0 and < 6.0.0" } 76 74 mist = { version = ">= 5.0.3 and < 6.0.0" } 77 75 simplifile = { version = ">= 2.0.0 and < 3.0.0" } 78 76 sqlight = { version = ">= 1.0.0 and < 2.0.0" } 77 + swell = { version = ">= 1.0.0 and < 2.0.0" } 79 78 thoas = { version = ">= 1.0.0 and < 2.0.0" } 80 79 wisp = { version = ">= 2.1.0 and < 3.0.0" } 81 80 wisp_flash = { version = ">= 2.0.0 and < 3.0.0" } 82 - swell = { version = ">= 1.0.0 and < 2.0.0" }
+2
server/priv/static/index.html
··· 1 + <!doctype html> 2 + <html lang="en"><head><meta charset="utf-8"><meta content="width=device-width, initial-scale=1" name="viewport"><title>quickslice</title><link href="/styles.css" rel="stylesheet"><script src="/quickslice_client.js" type="module"></script></head><body><div id="app"></div></body></html>
+61
server/priv/static/quickslice_client.js
··· 1 + class I{withFields(Z){let W=Object.keys(this).map((J)=>(J in Z)?Z[J]:this[J]);return new this.constructor(...W)}}class LZ{static fromArray(Z,W){let J=W||new T;for(let K=Z.length-1;K>=0;--K)J=new tZ(Z[K],J);return J}[Symbol.iterator](){return new EK(this)}toArray(){return[...this]}atLeastLength(Z){let W=this;while(Z-- >0&&W)W=W.tail;return W!==void 0}hasLength(Z){let W=this;while(Z-- >0&&W)W=W.tail;return Z===-1&&W instanceof T}countLength(){let Z=this,W=0;while(Z)Z=Z.tail,W++;return W-1}}function P(Z,W){return new tZ(Z,W)}function Y(Z,W){return LZ.fromArray(Z,W)}class EK{#Z;constructor(Z){this.#Z=Z}next(){if(this.#Z instanceof T)return{done:!0};else{let{head:Z,tail:W}=this.#Z;return this.#Z=W,{value:Z,done:!1}}}}class T extends LZ{}class tZ extends LZ{constructor(Z,W){super();this.head=Z,this.tail=W}}var xK=(Z)=>Z instanceof tZ,kK=(Z)=>Z.head,fK=(Z)=>Z.tail;class O8{bitSize;byteSize;bitOffset;rawBuffer;constructor(Z,W,J){if(!(Z instanceof Uint8Array))throw globalThis.Error("BitArray can only be constructed from a Uint8Array");if(this.bitSize=W??Z.length*8,this.byteSize=Math.trunc((this.bitSize+7)/8),this.bitOffset=J??0,this.bitSize<0)throw globalThis.Error(`BitArray bit size is invalid: ${this.bitSize}`);if(this.bitOffset<0||this.bitOffset>7)throw globalThis.Error(`BitArray bit offset is invalid: ${this.bitOffset}`);if(Z.length!==Math.trunc((this.bitOffset+this.bitSize+7)/8))throw globalThis.Error("BitArray buffer length is invalid");this.rawBuffer=Z}byteAt(Z){if(Z<0||Z>=this.byteSize)return;return j8(this.rawBuffer,this.bitOffset,Z)}equals(Z){if(this.bitSize!==Z.bitSize)return!1;let W=Math.trunc(this.bitSize/8);if(this.bitOffset===0&&Z.bitOffset===0){for(let K=0;K<W;K++)if(this.rawBuffer[K]!==Z.rawBuffer[K])return!1;let J=this.bitSize%8;if(J){let K=8-J;if(this.rawBuffer[W]>>K!==Z.rawBuffer[W]>>K)return!1}}else{for(let K=0;K<W;K++){let X=j8(this.rawBuffer,this.bitOffset,K),V=j8(Z.rawBuffer,Z.bitOffset,K);if(X!==V)return!1}let J=this.bitSize%8;if(J){let K=j8(this.rawBuffer,this.bitOffset,W),X=j8(Z.rawBuffer,Z.bitOffset,W),V=8-J;if(K>>V!==X>>V)return!1}}return!0}get buffer(){if(wK("buffer","Use BitArray.byteAt() or BitArray.rawBuffer instead"),this.bitOffset!==0||this.bitSize%8!==0)throw new globalThis.Error("BitArray.buffer does not support unaligned bit arrays");return this.rawBuffer}get length(){if(wK("length","Use BitArray.bitSize or BitArray.byteSize instead"),this.bitOffset!==0||this.bitSize%8!==0)throw new globalThis.Error("BitArray.length does not support unaligned bit arrays");return this.rawBuffer.length}}function j8(Z,W,J){if(W===0)return Z[J]??0;else{let K=Z[J]<<W&255,X=Z[J+1]>>8-W;return K|X}}class F6{constructor(Z){this.value=Z}}var qK={};function wK(Z,W){if(qK[Z])return;console.warn(`Deprecated BitArray.${Z} property used in JavaScript FFI code. ${W}.`),qK[Z]=!0}class r0 extends I{static isResult(Z){return Z instanceof r0}}class H extends r0{constructor(Z){super();this[0]=Z}isOk(){return!0}}var yK=(Z)=>new H(Z);class q extends r0{constructor(Z){super();this[0]=Z}isOk(){return!1}}var bK=(Z)=>new q(Z);function GZ(Z,W){let J=[Z,W];while(J.length){let K=J.pop(),X=J.pop();if(K===X)continue;if(!LK(K)||!LK(X))return!1;if(!dQ(K,X)||_Q(K,X)||$Q(K,X)||mQ(K,X)||uQ(K,X)||cQ(K,X)||pQ(K,X))return!1;let Q=Object.getPrototypeOf(K);if(Q!==null&&typeof Q.equals==="function")try{if(K.equals(X))continue;else return!1}catch{}let[G,F]=vQ(K),M=G(K),U=G(X);if(M.length!==U.length)return!1;for(let z of M)J.push(F(K,z),F(X,z))}return!0}function vQ(Z){if(Z instanceof Map)return[(W)=>W.keys(),(W,J)=>W.get(J)];else{let W=Z instanceof globalThis.Error?["message"]:[];return[(J)=>[...W,...Object.keys(J)],(J,K)=>J[K]]}}function _Q(Z,W){return Z instanceof Date&&(Z>W||Z<W)}function $Q(Z,W){return!(Z instanceof O8)&&Z.buffer instanceof ArrayBuffer&&Z.BYTES_PER_ELEMENT&&!(Z.byteLength===W.byteLength&&Z.every((J,K)=>J===W[K]))}function mQ(Z,W){return Array.isArray(Z)&&Z.length!==W.length}function uQ(Z,W){return Z instanceof Map&&Z.size!==W.size}function cQ(Z,W){return Z instanceof Set&&(Z.size!=W.size||[...Z].some((J)=>!W.has(J)))}function pQ(Z,W){return Z instanceof RegExp&&(Z.source!==W.source||Z.flags!==W.flags)}function LK(Z){return typeof Z==="object"&&Z!==null}function dQ(Z,W){if(typeof Z!=="object"&&typeof W!=="object"&&(!Z||!W))return!1;if([Promise,WeakSet,WeakMap,Function].some((K)=>Z instanceof K))return!1;return Z.constructor===W.constructor}function JJ(Z,W){if(W===0)return 0;else return Z/W}function F0(Z,W,J,K,X,V,Q){let G=new globalThis.Error(V);G.gleam_error=Z,G.file=W,G.module=J,G.line=K,G.function=X,G.fn=X;for(let F in Q)G[F]=Q[F];return G}class TZ extends I{}class AZ extends I{}class cW extends I{}class L extends I{constructor(Z){super();this[0]=Z}}class g extends I{}function M6(Z,W){if(Z instanceof L){let J=Z[0];return new H(J)}else return new q(W)}function gK(Z,W){if(Z instanceof L)return Z[0];else return W}function U6(Z,W){if(Z instanceof L){let J=Z[0];return W(J)}else return Z}var hK=new WeakMap,I6=new DataView(new ArrayBuffer(8)),z6=0;function H6(Z){let W=hK.get(Z);if(W!==void 0)return W;let J=z6++;if(z6===2147483647)z6=0;return hK.set(Z,J),J}function N6(Z,W){return Z^W+2654435769+(Z<<6)+(Z>>2)|0}function B6(Z){let W=0,J=Z.length;for(let K=0;K<J;K++)W=Math.imul(31,W)+Z.charCodeAt(K)|0;return W}function _K(Z){I6.setFloat64(0,Z);let W=I6.getInt32(0),J=I6.getInt32(4);return Math.imul(73244475,W>>16^W)^J}function sQ(Z){return B6(Z.toString())}function rQ(Z){let W=Object.getPrototypeOf(Z);if(W!==null&&typeof W.hashCode==="function")try{let K=Z.hashCode(Z);if(typeof K==="number")return K}catch{}if(Z instanceof Promise||Z instanceof WeakSet||Z instanceof WeakMap)return H6(Z);if(Z instanceof Date)return _K(Z.getTime());let J=0;if(Z instanceof ArrayBuffer)Z=new Uint8Array(Z);if(Array.isArray(Z)||Z instanceof Uint8Array)for(let K=0;K<Z.length;K++)J=Math.imul(31,J)+QW(Z[K])|0;else if(Z instanceof Set)Z.forEach((K)=>{J=J+QW(K)|0});else if(Z instanceof Map)Z.forEach((K,X)=>{J=J+N6(QW(K),QW(X))|0});else{let K=Object.keys(Z);for(let X=0;X<K.length;X++){let V=K[X],Q=Z[V];J=J+N6(QW(Q),B6(V))|0}}return J}function QW(Z){if(Z===null)return 1108378658;if(Z===void 0)return 1108378659;if(Z===!0)return 1108378657;if(Z===!1)return 1108378656;switch(typeof Z){case"number":return _K(Z);case"string":return B6(Z);case"bigint":return sQ(Z);case"object":return rQ(Z);case"symbol":return H6(Z);case"function":return H6(Z);default:return 0}}var EW=5,P6=Math.pow(2,EW),nQ=P6-1,iQ=P6/2,lQ=P6/4,$Z=0,LW=1,eZ=2,A0=3,R6={type:eZ,bitmap:0,array:[]};function T8(Z,W){return Z>>>W&nQ}function XJ(Z,W){return 1<<T8(Z,W)}function aQ(Z){return Z-=Z>>1&1431655765,Z=(Z&858993459)+(Z>>2&858993459),Z=Z+(Z>>4)&252645135,Z+=Z>>8,Z+=Z>>16,Z&127}function j6(Z,W){return aQ(Z&W-1)}function YW(Z,W,J){let K=Z.length,X=Array(K);for(let V=0;V<K;++V)X[V]=Z[V];return X[W]=J,X}function oQ(Z,W,J){let K=Z.length,X=Array(K+1),V=0,Q=0;while(V<W)X[Q++]=Z[V++];X[Q++]=J;while(V<K)X[Q++]=Z[V++];return X}function D6(Z,W){let J=Z.length,K=Array(J-1),X=0,V=0;while(X<W)K[V++]=Z[X++];++X;while(X<J)K[V++]=Z[X++];return K}function $K(Z,W,J,K,X,V){let Q=QW(W);if(Q===K)return{type:A0,hash:Q,array:[{type:$Z,k:W,v:J},{type:$Z,k:X,v:V}]};let G={val:!1};return A8(O6(R6,Z,Q,W,J,G),Z,K,X,V,G)}function A8(Z,W,J,K,X,V){switch(Z.type){case LW:return tQ(Z,W,J,K,X,V);case eZ:return O6(Z,W,J,K,X,V);case A0:return eQ(Z,W,J,K,X,V)}}function tQ(Z,W,J,K,X,V){let Q=T8(J,W),G=Z.array[Q];if(G===void 0)return V.val=!0,{type:LW,size:Z.size+1,array:YW(Z.array,Q,{type:$Z,k:K,v:X})};if(G.type===$Z){if(GZ(K,G.k)){if(X===G.v)return Z;return{type:LW,size:Z.size,array:YW(Z.array,Q,{type:$Z,k:K,v:X})}}return V.val=!0,{type:LW,size:Z.size,array:YW(Z.array,Q,$K(W+EW,G.k,G.v,J,K,X))}}let F=A8(G,W+EW,J,K,X,V);if(F===G)return Z;return{type:LW,size:Z.size,array:YW(Z.array,Q,F)}}function O6(Z,W,J,K,X,V){let Q=XJ(J,W),G=j6(Z.bitmap,Q);if((Z.bitmap&Q)!==0){let F=Z.array[G];if(F.type!==$Z){let U=A8(F,W+EW,J,K,X,V);if(U===F)return Z;return{type:eZ,bitmap:Z.bitmap,array:YW(Z.array,G,U)}}let M=F.k;if(GZ(K,M)){if(X===F.v)return Z;return{type:eZ,bitmap:Z.bitmap,array:YW(Z.array,G,{type:$Z,k:K,v:X})}}return V.val=!0,{type:eZ,bitmap:Z.bitmap,array:YW(Z.array,G,$K(W+EW,M,F.v,J,K,X))}}else{let F=Z.array.length;if(F>=iQ){let M=Array(32),U=T8(J,W);M[U]=O6(R6,W+EW,J,K,X,V);let z=0,D=Z.bitmap;for(let O=0;O<32;O++){if((D&1)!==0){let R=Z.array[z++];M[O]=R}D=D>>>1}return{type:LW,size:F+1,array:M}}else{let M=oQ(Z.array,G,{type:$Z,k:K,v:X});return V.val=!0,{type:eZ,bitmap:Z.bitmap|Q,array:M}}}}function eQ(Z,W,J,K,X,V){if(J===Z.hash){let Q=T6(Z,K);if(Q!==-1){if(Z.array[Q].v===X)return Z;return{type:A0,hash:J,array:YW(Z.array,Q,{type:$Z,k:K,v:X})}}let G=Z.array.length;return V.val=!0,{type:A0,hash:J,array:YW(Z.array,G,{type:$Z,k:K,v:X})}}return A8({type:eZ,bitmap:XJ(Z.hash,W),array:[Z]},W,J,K,X,V)}function T6(Z,W){let J=Z.array.length;for(let K=0;K<J;K++)if(GZ(W,Z.array[K].k))return K;return-1}function KJ(Z,W,J,K){switch(Z.type){case LW:return ZY(Z,W,J,K);case eZ:return WY(Z,W,J,K);case A0:return JY(Z,K)}}function ZY(Z,W,J,K){let X=T8(J,W),V=Z.array[X];if(V===void 0)return;if(V.type!==$Z)return KJ(V,W+EW,J,K);if(GZ(K,V.k))return V;return}function WY(Z,W,J,K){let X=XJ(J,W);if((Z.bitmap&X)===0)return;let V=j6(Z.bitmap,X),Q=Z.array[V];if(Q.type!==$Z)return KJ(Q,W+EW,J,K);if(GZ(K,Q.k))return Q;return}function JY(Z,W){let J=T6(Z,W);if(J<0)return;return Z.array[J]}function A6(Z,W,J,K){switch(Z.type){case LW:return KY(Z,W,J,K);case eZ:return XY(Z,W,J,K);case A0:return VY(Z,K)}}function KY(Z,W,J,K){let X=T8(J,W),V=Z.array[X];if(V===void 0)return Z;let Q=void 0;if(V.type===$Z){if(!GZ(V.k,K))return Z}else if(Q=A6(V,W+EW,J,K),Q===V)return Z;if(Q===void 0){if(Z.size<=lQ){let G=Z.array,F=Array(Z.size-1),M=0,U=0,z=0;while(M<X){let D=G[M];if(D!==void 0)F[U]=D,z|=1<<M,++U;++M}++M;while(M<G.length){let D=G[M];if(D!==void 0)F[U]=D,z|=1<<M,++U;++M}return{type:eZ,bitmap:z,array:F}}return{type:LW,size:Z.size-1,array:YW(Z.array,X,Q)}}return{type:LW,size:Z.size,array:YW(Z.array,X,Q)}}function XY(Z,W,J,K){let X=XJ(J,W);if((Z.bitmap&X)===0)return Z;let V=j6(Z.bitmap,X),Q=Z.array[V];if(Q.type!==$Z){let G=A6(Q,W+EW,J,K);if(G===Q)return Z;if(G!==void 0)return{type:eZ,bitmap:Z.bitmap,array:YW(Z.array,V,G)};if(Z.bitmap===X)return;return{type:eZ,bitmap:Z.bitmap^X,array:D6(Z.array,V)}}if(GZ(K,Q.k)){if(Z.bitmap===X)return;return{type:eZ,bitmap:Z.bitmap^X,array:D6(Z.array,V)}}return Z}function VY(Z,W){let J=T6(Z,W);if(J<0)return Z;if(Z.array.length===1)return;return{type:A0,hash:Z.hash,array:D6(Z.array,J)}}function mK(Z,W){if(Z===void 0)return;let J=Z.array,K=J.length;for(let X=0;X<K;X++){let V=J[X];if(V===void 0)continue;if(V.type===$Z){W(V.v,V.k);continue}mK(V,W)}}class CZ{static fromObject(Z){let W=Object.keys(Z),J=CZ.new();for(let K=0;K<W.length;K++){let X=W[K];J=J.set(X,Z[X])}return J}static fromMap(Z){let W=CZ.new();return Z.forEach((J,K)=>{W=W.set(K,J)}),W}static new(){return new CZ(void 0,0)}constructor(Z,W){this.root=Z,this.size=W}get(Z,W){if(this.root===void 0)return W;let J=KJ(this.root,0,QW(Z),Z);if(J===void 0)return W;return J.v}set(Z,W){let J={val:!1},K=this.root===void 0?R6:this.root,X=A8(K,0,QW(Z),Z,W,J);if(X===this.root)return this;return new CZ(X,J.val?this.size+1:this.size)}delete(Z){if(this.root===void 0)return this;let W=A6(this.root,0,QW(Z),Z);if(W===this.root)return this;if(W===void 0)return CZ.new();return new CZ(W,this.size-1)}has(Z){if(this.root===void 0)return!1;return KJ(this.root,0,QW(Z),Z)!==void 0}entries(){if(this.root===void 0)return[];let Z=[];return this.forEach((W,J)=>Z.push([J,W])),Z}forEach(Z){mK(this.root,Z)}hashCode(){let Z=0;return this.forEach((W,J)=>{Z=Z+N6(QW(W),QW(J))|0}),Z}equals(Z){if(!(Z instanceof CZ)||this.size!==Z.size)return!1;try{return this.forEach((W,J)=>{if(!GZ(Z.get(J,!W),W))throw vK}),!0}catch(W){if(W===vK)return!1;throw W}}}var vK=Symbol();function S6(Z){return VJ(Z)===0}function QY(Z,W){return!GZ(r(W,Z),new q(void 0))}function uK(Z,W){return QY(W,Z)}function OZ(Z,W,J){return pK(W,J,Z)}function YY(Z,W){while(!0){let J=Z,K=W;if(J instanceof T)return K;else{let X=J.head;Z=J.tail,W=P(X,K)}}}function GY(Z,W){while(!0){let J=Z,K=W;if(J instanceof T)return YY(K,Y([]));else{let X=J.tail,V=J.head[0];Z=X,W=P(V,K)}}}function pW(Z){return GY(U0(Z),Y([]))}function dW(Z,W){return cK(W,Z)}function FY(Z,W,J){while(!0){let K=Z,X=W,V=J;if(K instanceof T)return X;else{let Q=K.tail,G=K.head[0],F=K.head[1];Z=Q,W=V(X,G,F),J=V}}}function n0(Z,W,J){return FY(U0(Z),W,J)}class xZ extends I{}class i0 extends I{}function UY(Z,W){while(!0){let J=Z,K=W;if(J instanceof T)return K;else Z=J.tail,W=K+1}}function S8(Z){return UY(Z,0)}function S0(Z,W){while(!0){let J=Z,K=W;if(J instanceof T)return K;else{let X=J.head;Z=J.tail,W=P(X,K)}}}function l(Z){return S0(Z,Y([]))}function IY(Z,W,J){while(!0){let K=Z,X=W,V=J;if(K instanceof T)return l(V);else{let{head:Q,tail:G}=K,F,M=X(Q);if(M instanceof H){let z=M[0];F=P(z,V)}else F=V;let U=F;Z=G,W=X,J=U}}}function l0(Z,W){return IY(Z,W,Y([]))}function zY(Z,W,J){while(!0){let K=Z,X=W,V=J;if(K instanceof T)return l(V);else{let Q=K.head;Z=K.tail,W=X,J=P(X(Q),V)}}}function HZ(Z,W){return zY(Z,W,Y([]))}function HY(Z,W,J,K){while(!0){let X=Z,V=W,Q=J,G=K;if(X instanceof T)return l(G);else{let{head:F,tail:M}=X,U=P(V(F,Q),G);Z=M,W=V,J=Q+1,K=U}}}function dK(Z,W){return HY(Z,W,0,Y([]))}function NY(Z,W){while(!0){let J=Z,K=W;if(J instanceof T)return K;else{let X=J.head;Z=J.tail,W=P(X,K)}}}function SZ(Z,W){return NY(l(Z),W)}function QJ(Z,W){return P(W,Z)}function DY(Z,W){while(!0){let J=Z,K=W;if(J instanceof T)return l(K);else{let X=J.head;Z=J.tail,W=S0(X,K)}}}function sK(Z){return DY(Z,Y([]))}function UZ(Z,W,J){while(!0){let K=Z,X=W,V=J;if(K instanceof T)return X;else{let Q=K.head;Z=K.tail,W=V(X,Q),J=V}}}function C6(Z,W){while(!0){let J=Z,K=W;if(J instanceof T)return new q(void 0);else{let{head:X,tail:V}=J,Q=K(X);if(Q instanceof H)return Q;else Z=V,W=K}}}function BY(Z,W,J){while(!0){let K=Z,X=W,V=J;if(K instanceof T)return l(V);else{let{head:Q,tail:G}=K;if(uK(X,Q))Z=G,W=X,J=V;else Z=G,W=OZ(X,Q,void 0),J=P(Q,V)}}}function rK(Z){return BY(Z,MZ(),Y([]))}function PY(Z,W,J,K,X,V){while(!0){let Q=Z,G=W,F=J,M=K,U=X,z=V,D=P(U,F);if(Q instanceof T)if(M instanceof xZ)return P(l(D),z);else return P(D,z);else{let{head:O,tail:R}=Q,C=G(U,O);if(M instanceof xZ)if(C instanceof TZ)Z=R,W=G,J=D,K=M,X=O,V=z;else if(C instanceof AZ)Z=R,W=G,J=D,K=M,X=O,V=z;else{let j;if(M instanceof xZ)j=P(l(D),z);else j=P(D,z);let E=j;if(R instanceof T)return P(Y([O]),E);else{let{head:A,tail:k}=R,b,B=G(O,A);if(B instanceof TZ)b=new xZ;else if(B instanceof AZ)b=new xZ;else b=new i0;let S=b;Z=k,W=G,J=Y([O]),K=S,X=A,V=E}}else if(C instanceof TZ){let j;if(M instanceof xZ)j=P(l(D),z);else j=P(D,z);let E=j;if(R instanceof T)return P(Y([O]),E);else{let{head:A,tail:k}=R,b,B=G(O,A);if(B instanceof TZ)b=new xZ;else if(B instanceof AZ)b=new xZ;else b=new i0;let S=b;Z=k,W=G,J=Y([O]),K=S,X=A,V=E}}else if(C instanceof AZ){let j;if(M instanceof xZ)j=P(l(D),z);else j=P(D,z);let E=j;if(R instanceof T)return P(Y([O]),E);else{let{head:A,tail:k}=R,b,B=G(O,A);if(B instanceof TZ)b=new xZ;else if(B instanceof AZ)b=new xZ;else b=new i0;let S=b;Z=k,W=G,J=Y([O]),K=S,X=A,V=E}}else Z=R,W=G,J=D,K=M,X=O,V=z}}}function RY(Z,W,J,K){while(!0){let X=Z,V=W,Q=J,G=K;if(X instanceof T)return S0(V,G);else if(V instanceof T)return S0(X,G);else{let{head:F,tail:M}=X,U=V.head,z=V.tail,D=Q(F,U);if(D instanceof TZ)Z=M,W=V,J=Q,K=P(F,G);else if(D instanceof AZ)Z=X,W=z,J=Q,K=P(U,G);else Z=X,W=z,J=Q,K=P(U,G)}}}function jY(Z,W,J){while(!0){let K=Z,X=W,V=J;if(K instanceof T)return l(V);else{let Q=K.tail;if(Q instanceof T){let G=K.head;return l(P(l(G),V))}else{let G=K.head,F=Q.head,M=Q.tail,U=RY(G,F,X,Y([]));Z=M,W=X,J=P(U,V)}}}}function OY(Z,W,J,K){while(!0){let X=Z,V=W,Q=J,G=K;if(X instanceof T)return S0(V,G);else if(V instanceof T)return S0(X,G);else{let{head:F,tail:M}=X,U=V.head,z=V.tail,D=Q(F,U);if(D instanceof TZ)Z=X,W=z,J=Q,K=P(U,G);else if(D instanceof AZ)Z=M,W=V,J=Q,K=P(F,G);else Z=M,W=V,J=Q,K=P(F,G)}}}function TY(Z,W,J){while(!0){let K=Z,X=W,V=J;if(K instanceof T)return l(V);else{let Q=K.tail;if(Q instanceof T){let G=K.head;return l(P(l(G),V))}else{let G=K.head,F=Q.head,M=Q.tail,U=OY(G,F,X,Y([]));Z=M,W=X,J=P(U,V)}}}}function AY(Z,W,J){while(!0){let K=Z,X=W,V=J;if(K instanceof T)return K;else if(X instanceof xZ)if(K.tail instanceof T)return K.head;else Z=jY(K,V,Y([])),W=new i0,J=V;else if(K.tail instanceof T){let G=K.head;return l(G)}else Z=TY(K,V,Y([])),W=new xZ,J=V}}function q6(Z,W){if(Z instanceof T)return Z;else{let J=Z.tail;if(J instanceof T)return Z;else{let K=Z.head,X=J.head,V=J.tail,Q,G=W(K,X);if(G instanceof TZ)Q=new xZ;else if(G instanceof AZ)Q=new xZ;else Q=new i0;let F=Q,M=PY(V,W,Y([K]),F,X,Y([]));return AY(M,new xZ,W)}}}function SY(Z,W,J,K){while(!0){let X=Z,V=W,Q=J,G=K;if(X instanceof T)return l(P([V,Q],G));else{let F=X.head[0];if(GZ(F,V)){let M=X.tail;return S0(G,P([F,Q],M))}else{let M=X.head;Z=X.tail,W=V,J=Q,K=P(M,G)}}}}function w6(Z,W,J){return SY(Z,W,J,Y([]))}function nK(Z,W){while(!0){let J=Z,K=W;if(J instanceof T)return;else{let{head:X,tail:V}=J;K(X),Z=V,W=K}}}function iK(Z,W){if(Z instanceof T)return new q(void 0);else{let{head:J,tail:K}=Z;return new H(UZ(K,J,W))}}class z0 extends I{constructor(Z,W,J){super();this.expected=Z,this.found=W,this.path=J}}class ZW extends I{constructor(Z){super();this.function=Z}}function s(Z,W){let J=W.function(Z),K,X;if(K=J[0],X=J[1],X instanceof T)return new H(K);else return new q(X)}function t(Z){return new ZW((W)=>{return[Z,Y([])]})}function wY(Z){return[Z,Y([])]}function C8(Z,W){return new ZW((J)=>{let K=Z.function(J),X,V;return X=K[0],V=K[1],[W(X),V]})}function LY(Z,W,J){while(!0){let K=Z,X=W,V=J;if(V instanceof T)return X;else{let{head:Q,tail:G}=V,F=Q.function(K),M,U;if(M=F,U=F[1],U instanceof T)return M;else Z=K,W=X,J=G}}}function oK(Z,W){return new ZW((J)=>{let K=Z.function(J),X,V;if(X=K,V=K[1],V instanceof T)return X;else return LY(J,X,W)})}function sW(Z){return new ZW((W)=>{if(QX(W))return[new g,Y([])];else{let K=Z.function(W),X,V;return X=K[0],V=K[1],[new L(X),V]}})}var NZ=new ZW(wY);function tK(Z,W){return Y([new z0(Z,I0(W),Y([]))])}function E6(Z,W,J){let K=J(Z);if(K instanceof H)return[K[0],Y([])];else return[K[0],Y([new z0(W,I0(Z),Y([]))])]}function EY(Z){if(GZ(n(!0),Z))return[!0,Y([])];else if(GZ(n(!1),Z))return[!1,Y([])];else return[!1,tK("Bool",Z)]}function xY(Z){return E6(Z,"Int",XX)}function kY(Z){return E6(Z,"Float",KX)}var BW=new ZW(EY),mZ=new ZW(xY),eK=new ZW(kY);function fY(Z){return E6(Z,"String",VX)}var c=new ZW(fY);function yY(Z,W,J,K,X){let V=K(W),Q=V[1];if(Q instanceof T){let G=V[0],F=X(J),M=F[1];if(M instanceof T){let U=F[0];return[OZ(Z[0],G,U),Z[1]]}else{let U=M;return a0([MZ(),U],Y(["values"]))}}else{let G=Q;return a0([MZ(),G],Y(["keys"]))}}function rW(Z,W){return new ZW((J)=>{let K=JX(J);if(K instanceof H){let X=K[0];return n0(X,[MZ(),Y([])],(V,Q,G)=>{if(V[1]instanceof T)return yY(V,Q,G,Z.function,W.function);else return V})}else return[MZ(),tK("Dict",J)]})}function PW(Z){return new ZW((W)=>{return WX(W,Z.function,(J,K)=>{return a0(J,Y([K]))},0,Y([]))})}function a0(Z,W){let J=oK(c,Y([(()=>{return C8(mZ,qZ)})()])),K=HZ(W,(V)=>{let Q=n(V),G=s(Q,J);if(G instanceof H)return G[0];else return"<"+I0(Q)+">"}),X=HZ(Z[1],(V)=>{return new z0(V.expected,V.found,SZ(K,V.path))});return[Z[0],X]}function bY(Z,W,J,K,X){while(!0){let V=Z,Q=W,G=J,F=K,M=X;if(V instanceof T){let z=G(F);return a0(z,l(Q))}else{let{head:U,tail:z}=V,D=ZX(F,U);if(D instanceof H){let O=D[0];if(O instanceof L){let R=O[0];Z=z,W=P(U,Q),J=G,K=R,X=M}else return M(F,P(U,Q))}else{let O=D[0],R=G(F),C;C=R[0];let j=[C,Y([new z0(O,I0(F),Y([]))])];return a0(j,l(Q))}}}}function x6(Z,W,J){return new ZW((K)=>{let X=bY(Z,Y([]),W.function,K,(U,z)=>{let D=W.function(U),O;O=D[0];let R=[O,Y([new z0("Field","Nothing",Y([]))])];return a0(R,l(z))}),V,Q;V=X[0],Q=X[1];let G=J(V).function(K),F,M;return F=G[0],M=G[1],[F,SZ(Q,M)]})}function $(Z,W,J){return x6(Y([Z]),W,J)}var k6=void 0,YX={};function n(Z){return Z}function qZ(Z){return Z.toString()}function GJ(Z){if(Z==="")return 0;let W=f6(Z);if(W){let J=0;for(let K of W)J++;return J}else return Z.match(/./gsu).length}var GX=void 0;function f6(Z){if(globalThis.Intl&&Intl.Segmenter)return GX||=new Intl.Segmenter,GX.segment(Z)[Symbol.iterator]()}function FJ(Z){let W,J=f6(Z);if(J)W=J.next().value?.segment;else W=Z.match(/./su)?.[0];if(W)return new H([W,Z.slice(W.length)]);else return new q(k6)}function C0(Z){return[Z.charCodeAt(0)|0,Z.slice(1)]}function FW(Z){return Z.toLowerCase()}function MJ(Z){return Z.toUpperCase()}function y6(Z,W,J){if(J<=0||W>=Z.length)return"";let K=f6(Z);if(K){while(W-- >0)K.next();let X="";while(J-- >0){let V=K.next().value;if(V===void 0)break;X+=V.segment}return X}else return Z.match(/./gsu).slice(W,W+J).join("")}function kZ(Z,W,J){return Z.slice(W,W+J)}function o0(Z,W){return Z.startsWith(W)}function UJ(Z,W){return Z.endsWith(W)}function IJ(Z,W){let J=Z.indexOf(W);if(J>=0){let K=Z.slice(0,J),X=Z.slice(J+W.length);return new H([K,X])}else return new q(k6)}var UX=[" ","\t",` 2 + `,"\v","\f","\r","…","\u2028","\u2029"].join(""),GF=new RegExp(`^[${UX}]*`),FF=new RegExp(`[${UX}]*$`);function q0(Z){console.log(Z)}function MZ(){return CZ.new()}function VJ(Z){return Z.size}function U0(Z){return LZ.fromArray(Z.entries())}function cK(Z,W){return W.delete(Z)}function r(Z,W){let J=Z.get(W,YX);if(J===YX)return new q(k6);return new H(J)}function pK(Z,W,J){return J.set(Z,W)}function I0(Z){if(typeof Z==="string")return"String";else if(typeof Z==="boolean")return"Bool";else if(Z instanceof r0)return"Result";else if(Z instanceof LZ)return"List";else if(Z instanceof O8)return"BitArray";else if(Z instanceof CZ)return"Dict";else if(Number.isInteger(Z))return"Int";else if(Array.isArray(Z))return"Array";else if(typeof Z==="number")return"Float";else if(Z===null)return"Nil";else if(Z===void 0)return"Nil";else{let W=typeof Z;return W.charAt(0).toUpperCase()+W.slice(1)}}function IX(Z){return new zX().inspect(Z)}function BZ(Z){let W=Z.toString().replace("+","");if(W.indexOf(".")>=0)return W;else{let J=W.indexOf("e");if(J>=0)return W.slice(0,J)+".0"+W.slice(J);else return W+".0"}}class zX{#Z=new Set;inspect(Z){let W=typeof Z;if(Z===!0)return"True";if(Z===!1)return"False";if(Z===null)return"//js(null)";if(Z===void 0)return"Nil";if(W==="string")return this.#V(Z);if(W==="bigint"||Number.isInteger(Z))return Z.toString();if(W==="number")return BZ(Z);if(Z instanceof F6)return this.#Q(Z);if(Z instanceof O8)return this.#G(Z);if(Z instanceof RegExp)return`//js(${Z})`;if(Z instanceof Date)return`//js(Date("${Z.toISOString()}"))`;if(Z instanceof globalThis.Error)return`//js(${Z.toString()})`;if(Z instanceof Function){let K=[];for(let X of Array(Z.length).keys())K.push(String.fromCharCode(X+97));return`//fn(${K.join(", ")}) { ... }`}if(this.#Z.size===this.#Z.add(Z).size)return"//js(circular reference)";let J;if(Array.isArray(Z))J=`#(${Z.map((K)=>this.inspect(K)).join(", ")})`;else if(Z instanceof LZ)J=this.#W(Z);else if(Z instanceof I)J=this.#K(Z);else if(Z instanceof CZ)J=this.#J(Z);else if(Z instanceof Set)return`//js(Set(${[...Z].map((K)=>this.inspect(K)).join(", ")}))`;else J=this.#X(Z);return this.#Z.delete(Z),J}#X(Z){let W=Object.getPrototypeOf(Z)?.constructor?.name||"Object",J=[];for(let V of Object.keys(Z))J.push(`${this.inspect(V)}: ${this.inspect(Z[V])}`);let K=J.length?" "+J.join(", ")+" ":"";return`//js(${W==="Object"?"":W+" "}{${K}})`}#J(Z){let W="dict.from_list([",J=!0;return Z.forEach((K,X)=>{if(!J)W=W+", ";W=W+"#("+this.inspect(X)+", "+this.inspect(K)+")",J=!1}),W+"])"}#K(Z){let W=Object.keys(Z).map((J)=>{let K=this.inspect(Z[J]);return isNaN(parseInt(J))?`${J}: ${K}`:K}).join(", ");return W?`${Z.constructor.name}(${W})`:Z.constructor.name}#W(Z){if(Z instanceof T)return"[]";let W='charlist.from_string("',J="[",K=Z;while(K instanceof tZ){let X=K.head;if(K=K.tail,J!=="[")J+=", ";if(J+=this.inspect(X),W)if(Number.isInteger(X)&&X>=32&&X<=126)W+=String.fromCharCode(X);else W=null}if(W)return W+'")';else return J+"]"}#V(Z){let W='"';for(let J=0;J<Z.length;J++){let K=Z[J];switch(K){case` 3 + `:W+="\\n";break;case"\r":W+="\\r";break;case"\t":W+="\\t";break;case"\f":W+="\\f";break;case"\\":W+="\\\\";break;case'"':W+="\\\"";break;default:if(K<" "||K>"~"&&K<" ")W+="\\u{"+K.charCodeAt(0).toString(16).toUpperCase().padStart(4,"0")+"}";else W+=K}}return W+='"',W}#Q(Z){return`//utfcodepoint(${String.fromCodePoint(Z.value)})`}#G(Z){if(Z.bitSize===0)return"<<>>";let W="<<";for(let J=0;J<Z.byteSize-1;J++)W+=Z.byteAt(J).toString(),W+=", ";if(Z.byteSize*8===Z.bitSize)W+=Z.byteAt(Z.byteSize-1).toString();else{let J=Z.bitSize%8;W+=Z.byteAt(Z.byteSize-1)>>8-J,W+=`:size(${J})`}return W+=">>",W}}function ZX(Z,W){if(Z instanceof CZ||Z instanceof WeakMap||Z instanceof Map){let K={},X=Z.get(W,K);if(X===K)return new H(new g);return new H(new L(X))}let J=Number.isInteger(W);if(J&&W>=0&&W<8&&Z instanceof LZ){let K=0;for(let X of Z){if(K===W)return new H(new L(X));K++}return new q("Indexable")}if(J&&Array.isArray(Z)||Z&&typeof Z==="object"||Z&&Object.getPrototypeOf(Z)===Object.prototype){if(W in Z)return new H(new L(Z[W]));return new H(new g)}return new q(J?"Indexable":"Dict")}function WX(Z,W,J,K,X){if(!(Z instanceof LZ||Array.isArray(Z))){let Q=new z0("List",I0(Z),X);return[X,LZ.fromArray([Q])]}let V=[];for(let Q of Z){let G=W(Q),[F,M]=G;if(M instanceof tZ){let[U,z]=J(G,K.toString());return[X,z]}V.push(F),K++}return[LZ.fromArray(V),X]}function JX(Z){if(Z instanceof CZ)return new H(Z);if(Z instanceof Map||Z instanceof WeakMap)return new H(CZ.fromMap(Z));if(Z==null)return new q("Dict");if(typeof Z!=="object")return new q("Dict");let W=Object.getPrototypeOf(Z);if(W===Object.prototype||W===null)return new H(CZ.fromObject(Z));return new q("Dict")}function KX(Z){if(typeof Z==="number")return new H(Z);return new q(0)}function XX(Z){if(Number.isInteger(Z))return new H(Z);return new q(0)}function VX(Z){if(typeof Z==="string")return new H(Z);return new q("")}function QX(Z){return Z===null||Z===void 0}function zJ(Z,W){if(Z>W)return Z;else return W}function NX(Z,W,J){if(J<=0)return"";else if(W<0){let V=GJ(Z)+W;if(V<0)return"";else return y6(Z,V,J)}else return y6(Z,W,J)}function dY(Z,W){while(!0){let J=Z,K=W;if(J instanceof T)return K;else{let X=J.head;Z=J.tail,W=K+X}}}function q8(Z){return dY(Z,"")}function sY(Z,W,J){while(!0){let K=Z,X=W,V=J;if(K instanceof T)return V;else{let Q=K.head;Z=K.tail,W=X,J=V+X+Q}}}function e0(Z,W){if(Z instanceof T)return"";else{let{head:J,tail:K}=Z;return sY(K,W,J)}}function DX(Z){let J=IX(Z);return n(J)}function RX(Z){if(Z instanceof H)return!0;else return!1}function Z8(Z,W){if(Z instanceof H)return Z;else{let J=Z[0];return new q(W(J))}}function rZ(Z,W){if(Z instanceof H){let J=Z[0];return W(J)}else return Z}function W8(Z,W){if(Z instanceof H)return Z[0];else return W}function b6(Z){return JSON.stringify(Z)}function jX(Z){return Object.fromEntries(Z)}function H0(Z){return Z}function OX(Z){let W=[];while(xK(Z))W.push(kK(Z)),Z=fK(Z);return W}function TX(){return null}function AX(Z){try{let W=JSON.parse(Z);return yK(W)}catch(W){return bK(aY(W,Z))}}function aY(Z,W){if(oY(Z))return SX();return tY(Z,W)}function oY(Z){return/((unexpected (end|eof))|(end of data)|(unterminated string)|(json( parse error|\.parse)\: expected '(\:|\}|\])'))/i.test(Z.message)}function tY(Z,W){let J=[eY,ZG,JG,WG];for(let K of J){let X=K(Z,W);if(X)return X}return J8("")}function eY(Z){let J=/unexpected token '(.)', ".+" is not valid JSON/i.exec(Z.message);if(!J)return null;let K=NJ(J[1]);return J8(K)}function ZG(Z){let J=/unexpected token (.) in JSON at position (\d+)/i.exec(Z.message);if(!J)return null;let K=NJ(J[1]);return J8(K)}function WG(Z,W){let K=/(unexpected character|expected .*) at line (\d+) column (\d+)/i.exec(Z.message);if(!K)return null;let X=Number(K[2]),V=Number(K[3]),Q=KG(X,V,W),G=NJ(W[Q]);return J8(G)}function JG(Z){let J=/unexpected (identifier|token) "(.)"/i.exec(Z.message);if(!J)return null;let K=NJ(J[2]);return J8(K)}function NJ(Z){return"0x"+Z.charCodeAt(0).toString(16).toUpperCase()}function KG(Z,W,J){if(Z===1)return W-1;let K=1,X=0;return J.split("").find((V,Q)=>{if(V===` 4 + `)K+=1;if(K===Z)return X=Q+W,!0;return!1}),X}class CX extends I{}var SX=()=>new CX;class qX extends I{constructor(Z){super();this[0]=Z}}var J8=(Z)=>new qX(Z);class wX extends I{constructor(Z){super();this[0]=Z}}function XG(Z,W){return rZ(AX(Z),(J)=>{let K=s(J,W);return Z8(K,(X)=>{return new wX(X)})})}function xW(Z,W){return XG(Z,W)}function nW(Z){return b6(Z)}function a(Z){return H0(Z)}function iW(Z){return H0(Z)}function WW(Z){return H0(Z)}function LX(Z){return H0(Z)}function DJ(){return TX()}function K8(Z,W){if(Z instanceof L){let J=Z[0];return W(J)}else return DJ()}function h(Z){return jX(Z)}function EX(Z){return OX(Z)}function kW(Z,W){let K=HZ(Z,W);return EX(K)}class d extends I{constructor(Z,W,J,K,X,V,Q){super();this.scheme=Z,this.userinfo=W,this.host=J,this.port=K,this.path=X,this.query=V,this.fragment=Q}}function YG(Z){return 48>=Z&&Z<=57||65>=Z&&Z<=90||97>=Z&&Z<=122||Z===58||Z===46}function RW(Z,W){return new H(new d(W.scheme,W.userinfo,W.host,W.port,W.path,W.query,new L(Z)))}function GG(Z,W,J,K){while(!0){let X=Z,V=W,Q=J,G=K;if(V.startsWith("#"))if(G===0){let F=V.slice(1);return RW(F,Q)}else{let F=V.slice(1),M=kZ(X,0,G),U=new d(Q.scheme,Q.userinfo,Q.host,Q.port,Q.path,new L(M),Q.fragment);return RW(F,U)}else if(V==="")return new H(new d(Q.scheme,Q.userinfo,Q.host,Q.port,Q.path,new L(X),Q.fragment));else{let F=C0(V),M;M=F[1],Z=X,W=M,J=Q,K=G+1}}}function lW(Z,W){return GG(Z,Z,W,0)}function FG(Z,W,J,K){while(!0){let X=Z,V=W,Q=J,G=K;if(V.startsWith("?")){let F=V.slice(1),M=kZ(X,0,G),U=new d(Q.scheme,Q.userinfo,Q.host,Q.port,M,Q.query,Q.fragment);return lW(F,U)}else if(V.startsWith("#")){let F=V.slice(1),M=kZ(X,0,G),U=new d(Q.scheme,Q.userinfo,Q.host,Q.port,M,Q.query,Q.fragment);return RW(F,U)}else if(V==="")return new H(new d(Q.scheme,Q.userinfo,Q.host,Q.port,X,Q.query,Q.fragment));else{let F=C0(V),M;M=F[1],Z=X,W=M,J=Q,K=G+1}}}function w0(Z,W){return FG(Z,Z,W,0)}function fW(Z,W,J){while(!0){let K=Z,X=W,V=J;if(K.startsWith("0"))Z=K.slice(1),W=X,J=V*10;else if(K.startsWith("1"))Z=K.slice(1),W=X,J=V*10+1;else if(K.startsWith("2"))Z=K.slice(1),W=X,J=V*10+2;else if(K.startsWith("3"))Z=K.slice(1),W=X,J=V*10+3;else if(K.startsWith("4"))Z=K.slice(1),W=X,J=V*10+4;else if(K.startsWith("5"))Z=K.slice(1),W=X,J=V*10+5;else if(K.startsWith("6"))Z=K.slice(1),W=X,J=V*10+6;else if(K.startsWith("7"))Z=K.slice(1),W=X,J=V*10+7;else if(K.startsWith("8"))Z=K.slice(1),W=X,J=V*10+8;else if(K.startsWith("9"))Z=K.slice(1),W=X,J=V*10+9;else if(K.startsWith("?")){let Q=K.slice(1),G=new d(X.scheme,X.userinfo,X.host,new L(V),X.path,X.query,X.fragment);return lW(Q,G)}else if(K.startsWith("#")){let Q=K.slice(1),G=new d(X.scheme,X.userinfo,X.host,new L(V),X.path,X.query,X.fragment);return RW(Q,G)}else if(K.startsWith("/")){let Q=new d(X.scheme,X.userinfo,X.host,new L(V),X.path,X.query,X.fragment);return w0(K,Q)}else if(K==="")return new H(new d(X.scheme,X.userinfo,X.host,new L(V),X.path,X.query,X.fragment));else return new q(void 0)}}function BJ(Z,W){if(Z.startsWith(":0")){let J=Z.slice(2);return fW(J,W,0)}else if(Z.startsWith(":1")){let J=Z.slice(2);return fW(J,W,1)}else if(Z.startsWith(":2")){let J=Z.slice(2);return fW(J,W,2)}else if(Z.startsWith(":3")){let J=Z.slice(2);return fW(J,W,3)}else if(Z.startsWith(":4")){let J=Z.slice(2);return fW(J,W,4)}else if(Z.startsWith(":5")){let J=Z.slice(2);return fW(J,W,5)}else if(Z.startsWith(":6")){let J=Z.slice(2);return fW(J,W,6)}else if(Z.startsWith(":7")){let J=Z.slice(2);return fW(J,W,7)}else if(Z.startsWith(":8")){let J=Z.slice(2);return fW(J,W,8)}else if(Z.startsWith(":9")){let J=Z.slice(2);return fW(J,W,9)}else if(Z===":")return new H(W);else if(Z==="")return new H(W);else if(Z.startsWith("?")){let J=Z.slice(1);return lW(J,W)}else if(Z.startsWith(":?")){let J=Z.slice(2);return lW(J,W)}else if(Z.startsWith("#")){let J=Z.slice(1);return RW(J,W)}else if(Z.startsWith(":#")){let J=Z.slice(2);return RW(J,W)}else if(Z.startsWith("/"))return w0(Z,W);else if(Z.startsWith(":")){let J=Z.slice(1);if(J.startsWith("/"))return w0(J,W);else return new q(void 0)}else return new q(void 0)}function fX(Z,W,J,K){while(!0){let X=Z,V=W,Q=J,G=K;if(V==="")return new H(new d(Q.scheme,Q.userinfo,new L(X),Q.port,Q.path,Q.query,Q.fragment));else if(V.startsWith(":")){let F=kZ(X,0,G),M=new d(Q.scheme,Q.userinfo,new L(F),Q.port,Q.path,Q.query,Q.fragment);return BJ(V,M)}else if(V.startsWith("/")){let F=kZ(X,0,G),M=new d(Q.scheme,Q.userinfo,new L(F),Q.port,Q.path,Q.query,Q.fragment);return w0(V,M)}else if(V.startsWith("?")){let F=V.slice(1),M=kZ(X,0,G),U=new d(Q.scheme,Q.userinfo,new L(M),Q.port,Q.path,Q.query,Q.fragment);return lW(F,U)}else if(V.startsWith("#")){let F=V.slice(1),M=kZ(X,0,G),U=new d(Q.scheme,Q.userinfo,new L(M),Q.port,Q.path,Q.query,Q.fragment);return RW(F,U)}else{let F=C0(V),M;M=F[1],Z=X,W=M,J=Q,K=G+1}}}function MG(Z,W,J,K){while(!0){let X=Z,V=W,Q=J,G=K;if(V==="")return new H(new d(Q.scheme,Q.userinfo,new L(V),Q.port,Q.path,Q.query,Q.fragment));else if(V.startsWith("]"))if(G===0){let F=V.slice(1);return BJ(F,Q)}else{let F=V.slice(1),M=kZ(X,0,G+1),U=new d(Q.scheme,Q.userinfo,new L(M),Q.port,Q.path,Q.query,Q.fragment);return BJ(F,U)}else if(V.startsWith("/"))if(G===0)return w0(V,Q);else{let F=kZ(X,0,G),M=new d(Q.scheme,Q.userinfo,new L(F),Q.port,Q.path,Q.query,Q.fragment);return w0(V,M)}else if(V.startsWith("?"))if(G===0){let F=V.slice(1);return lW(F,Q)}else{let F=V.slice(1),M=kZ(X,0,G),U=new d(Q.scheme,Q.userinfo,new L(M),Q.port,Q.path,Q.query,Q.fragment);return lW(F,U)}else if(V.startsWith("#"))if(G===0){let F=V.slice(1);return RW(F,Q)}else{let F=V.slice(1),M=kZ(X,0,G),U=new d(Q.scheme,Q.userinfo,new L(M),Q.port,Q.path,Q.query,Q.fragment);return RW(F,U)}else{let F=C0(V),M,U;if(M=F[0],U=F[1],YG(M))Z=X,W=U,J=Q,K=G+1;else return fX(X,X,Q,0)}}}function UG(Z,W){return MG(Z,Z,W,0)}function IG(Z,W){return fX(Z,Z,W,0)}function X8(Z,W){if(Z.startsWith("["))return UG(Z,W);else if(Z.startsWith(":")){let J=new d(W.scheme,W.userinfo,new L(""),W.port,W.path,W.query,W.fragment);return BJ(Z,J)}else if(Z==="")return new H(new d(W.scheme,W.userinfo,new L(""),W.port,W.path,W.query,W.fragment));else return IG(Z,W)}function zG(Z,W,J,K){while(!0){let X=Z,V=W,Q=J,G=K;if(V.startsWith("@"))if(G===0){let F=V.slice(1);return X8(F,Q)}else{let F=V.slice(1),M=kZ(X,0,G),U=new d(Q.scheme,new L(M),Q.host,Q.port,Q.path,Q.query,Q.fragment);return X8(F,U)}else if(V==="")return X8(X,Q);else if(V.startsWith("/"))return X8(X,Q);else if(V.startsWith("?"))return X8(X,Q);else if(V.startsWith("#"))return X8(X,Q);else{let F=C0(V),M;M=F[1],Z=X,W=M,J=Q,K=G+1}}}function HG(Z,W){return zG(Z,Z,W,0)}function g6(Z,W){if(Z==="//")return new H(new d(W.scheme,W.userinfo,new L(""),W.port,W.path,W.query,W.fragment));else if(Z.startsWith("//")){let J=Z.slice(2);return HG(J,W)}else return w0(Z,W)}function NG(Z,W,J,K){while(!0){let X=Z,V=W,Q=J,G=K;if(V.startsWith("/"))if(G===0)return g6(V,Q);else{let F=kZ(X,0,G),M=new d(new L(FW(F)),Q.userinfo,Q.host,Q.port,Q.path,Q.query,Q.fragment);return g6(V,M)}else if(V.startsWith("?"))if(G===0){let F=V.slice(1);return lW(F,Q)}else{let F=V.slice(1),M=kZ(X,0,G),U=new d(new L(FW(M)),Q.userinfo,Q.host,Q.port,Q.path,Q.query,Q.fragment);return lW(F,U)}else if(V.startsWith("#"))if(G===0){let F=V.slice(1);return RW(F,Q)}else{let F=V.slice(1),M=kZ(X,0,G),U=new d(new L(FW(M)),Q.userinfo,Q.host,Q.port,Q.path,Q.query,Q.fragment);return RW(F,U)}else if(V.startsWith(":"))if(G===0)return new q(void 0);else{let F=V.slice(1),M=kZ(X,0,G),U=new d(new L(FW(M)),Q.userinfo,Q.host,Q.port,Q.path,Q.query,Q.fragment);return g6(F,U)}else if(V==="")return new H(new d(Q.scheme,Q.userinfo,Q.host,Q.port,X,Q.query,Q.fragment));else{let F=C0(V),M;M=F[1],Z=X,W=M,J=Q,K=G+1}}}function PJ(Z){let W,J=Z.fragment;if(J instanceof L){let B=J[0];W=Y(["#",B])}else W=Y([]);let K=W,X,V=Z.query;if(V instanceof L){let B=V[0];X=P("?",P(B,K))}else X=K;let Q=X,G=P(Z.path,Q),F,M=Z.host,U=o0(Z.path,"/");if(M instanceof L&&!U)if(M[0]!=="")F=P("/",G);else F=G;else F=G;let z=F,D,O=Z.host,R=Z.port;if(O instanceof L&&R instanceof L){let B=R[0];D=P(":",P(qZ(B),z))}else D=z;let C=D,j,E=Z.scheme,A=Z.userinfo,k=Z.host;if(E instanceof L)if(A instanceof L)if(k instanceof L){let B=E[0],S=A[0],y=k[0];j=P(B,P("://",P(S,P("@",P(y,C)))))}else{let B=E[0];j=P(B,P(":",C))}else if(k instanceof L){let B=E[0],S=k[0];j=P(B,P("://",P(S,C)))}else{let B=E[0];j=P(B,P(":",C))}else if(A instanceof g&&k instanceof L){let B=k[0];j=P("//",P(B,C))}else j=C;return q8(j)}var DG=new d(new g,new g,new g,new g,"",new g,new g);function h6(Z){return NG(Z,Z,DG,0)}function V8(Z,W,J){if(Z)return W;else return J()}function o(Z){return Z}class _6 extends I{constructor(Z){super();this.dict=Z}}function w8(){return new _6(MZ())}function hX(Z,W){let J=Z.dict,K=r(J,W);return RX(K)}function vX(Z){return pW(Z.dict)}var OG=void 0;function $6(Z,W){return new _6(OZ(Z.dict,W,OG))}var MW=()=>globalThis?.document,OJ="http://www.w3.org/1999/xhtml",TJ=1,u6=3;var _X=!!globalThis.HTMLElement?.prototype?.moveBefore;var m=Y([]),AJ=new g;var TG=new cW,AG=new TZ,SG=new AZ;function SJ(Z,W){if(Z.name===W.name)return SG;else if(Z.name<W.name)return AG;else return TG}class UW extends I{constructor(Z,W,J){super();this.kind=Z,this.name=W,this.value=J}}class L8 extends I{constructor(Z,W,J){super();this.kind=Z,this.name=W,this.value=J}}class nZ extends I{constructor(Z,W,J,K,X,V,Q,G){super();this.kind=Z,this.name=W,this.handler=J,this.include=K,this.prevent_default=X,this.stop_propagation=V,this.debounce=Q,this.throttle=G}}class E8 extends I{constructor(Z,W,J){super();this.prevent_default=Z,this.stop_propagation=W,this.message=J}}class pX extends I{constructor(Z){super();this.kind=Z}}function xG(Z,W){while(!0){let J=Z,K=W;if(J instanceof T)return K;else{let X=J.head;if(X instanceof UW){let V=X.name;if(V==="")Z=J.tail,W=K;else if(V==="class"){let Q=X.value;if(Q==="")Z=J.tail,W=K;else{let G=J.tail;if(G instanceof T){let F=X;Z=G,W=P(F,K)}else{let F=G.head;if(F instanceof UW)if(F.name==="class"){let U=X.kind,z=Q,D=G.tail,O=F.value,R=z+" "+O,C=new UW(U,"class",R);Z=P(C,D),W=K}else{let U=X;Z=G,W=P(U,K)}else{let M=X;Z=G,W=P(M,K)}}}}else if(V==="style"){let Q=X.value;if(Q==="")Z=J.tail,W=K;else{let G=J.tail;if(G instanceof T){let F=X;Z=G,W=P(F,K)}else{let F=G.head;if(F instanceof UW)if(F.name==="style"){let U=X.kind,z=Q,D=G.tail,O=F.value,R=z+";"+O,C=new UW(U,"style",R);Z=P(C,D),W=K}else{let U=X;Z=G,W=P(U,K)}else{let M=X;Z=G,W=P(M,K)}}}}else{let Q=X;Z=J.tail,W=P(Q,K)}}else{let V=X;Z=J.tail,W=P(V,K)}}}}function dX(Z){if(Z instanceof T)return Z;else if(Z.tail instanceof T)return Z;else{let K=q6(Z,(X,V)=>{return SJ(V,X)});return xG(K,m)}}var p6=0;function sX(Z,W){return new UW(p6,Z,W)}var d6=1;function rX(Z,W){return new L8(d6,Z,W)}var s6=2;function nX(Z,W,J,K,X,V,Q){return new nZ(s6,Z,W,J,K,X,V,Q)}var r6=0;var n6=new pX(r6);var i6=2;function w(Z,W){return sX(Z,W)}function iX(Z,W){return rX(Z,W)}function lX(Z,W){if(W)return w(Z,"");else return iX(Z,iW(!1))}function N(Z){return w("class",Z)}function x8(Z){return w("id",Z)}function L0(Z){return w("href",Z)}function l6(Z){return w("action",Z)}function a6(Z){return w("method",Z)}function aX(Z){return w("accept",e0(Z,","))}function k8(Z){return lX("disabled",Z)}function oX(Z){return w("name",Z)}function f8(Z){return w("placeholder",Z)}function tX(Z){return lX("required",Z)}function bW(Z){return w("type",Z)}function o6(Z){return w("value",Z)}class qJ extends I{constructor(Z,W,J){super();this.synchronous=Z,this.before_paint=W,this.after_paint=J}}class eX extends I{constructor(Z,W,J,K,X){super();this.dispatch=Z,this.emit=W,this.select=J,this.root=K,this.provide=X}}function Z7(Z,W,J,K,X,V){let Q=new eX(W,J,K,X,V);return nK(Z.synchronous,(G)=>{return G(Q)})}var CJ=new qJ(Y([]),Y([]),Y([]));function iZ(){return CJ}function aW(Z){return new qJ(Y([(J)=>{let K=J.dispatch;return Z(K)}]),CJ.before_paint,CJ.after_paint)}function jW(Z){return UZ(Z,CJ,(W,J)=>{return new qJ(UZ(J.synchronous,W.synchronous,QJ),UZ(J.before_paint,W.before_paint,QJ),UZ(J.after_paint,W.after_paint,QJ))})}function lZ(){return null}function y8(Z,W){let J=Z?.get(W);if(J!=null)return new H(J);else return new q(void 0)}function b8(Z,W){return Z&&Z.has(W)}function E0(Z,W,J){return Z??=new Map,Z.set(W,J),Z}function t6(Z,W){return Z?.delete(W),Z}class e6 extends I{}class Z9 extends I{constructor(Z,W){super();this.key=Z,this.parent=W}}class W7 extends I{constructor(Z,W){super();this.index=Z,this.parent=W}}function kG(Z,W){while(!0){let J=Z,K=W;if(K instanceof T)return!1;else{let{head:X,tail:V}=K,Q=o0(J,X);if(Q)return Q;else Z=J,W=V}}}function IW(Z,W,J){if(J==="")return new W7(W,Z);else return new Z9(J,Z)}var Y8=new e6,h8="\t";function J7(Z,W){while(!0){let J=Z,K=W;if(J instanceof e6)if(K instanceof T)return"";else{let X=K.tail;return q8(X)}else if(J instanceof Z9){let X=J.key;Z=J.parent,W=P(h8,P(X,K))}else{let X=J.index;Z=J.parent,W=P(h8,P(qZ(X),K))}}}function K7(Z){return J7(Z,Y([]))}function X7(Z,W){if(W instanceof T)return!1;else return kG(K7(Z),W)}var W9=` 5 + `;function J9(Z,W){return J7(Z,Y([W9,W]))}class aZ extends I{constructor(Z,W,J,K,X){super();this.kind=Z,this.key=W,this.mapper=J,this.children=K,this.keyed_children=X}}class uZ extends I{constructor(Z,W,J,K,X,V,Q,G,F,M){super();this.kind=Z,this.key=W,this.mapper=J,this.namespace=K,this.tag=X,this.attributes=V,this.children=Q,this.keyed_children=G,this.self_closing=F,this.void=M}}class JW extends I{constructor(Z,W,J,K){super();this.kind=Z,this.key=W,this.mapper=J,this.content=K}}class D0 extends I{constructor(Z,W,J,K,X,V,Q){super();this.kind=Z,this.key=W,this.mapper=J,this.namespace=K,this.tag=X,this.attributes=V,this.inner_html=Q}}function G8(Z,W){if(W==="")if(Z==="area")return!0;else if(Z==="base")return!0;else if(Z==="br")return!0;else if(Z==="col")return!0;else if(Z==="embed")return!0;else if(Z==="hr")return!0;else if(Z==="img")return!0;else if(Z==="input")return!0;else if(Z==="link")return!0;else if(Z==="meta")return!0;else if(Z==="param")return!0;else if(Z==="source")return!0;else if(Z==="track")return!0;else if(Z==="wbr")return!0;else return!1;else return!1}function V7(Z,W){if(W instanceof aZ)return new aZ(W.kind,Z,W.mapper,W.children,W.keyed_children);else if(W instanceof uZ)return new uZ(W.kind,Z,W.mapper,W.namespace,W.tag,W.attributes,W.children,W.keyed_children,W.self_closing,W.void);else if(W instanceof JW)return new JW(W.kind,Z,W.mapper,W.content);else return new D0(W.kind,Z,W.mapper,W.namespace,W.tag,W.attributes,W.inner_html)}var OW=0;function K9(Z,W,J,K){return new aZ(OW,Z,W,J,K)}var x0=1;function F8(Z,W,J,K,X,V,Q,G,F){return new uZ(x0,Z,W,J,K,dX(X),V,Q,G,F)}var M8=2;function X9(Z,W,J){return new JW(M8,Z,W,J)}var Q7=3;var V9=(Z,W)=>Z===W,TW=(Z,W)=>{if(Z===W)return!0;if(Z==null||W==null)return!1;let J=typeof Z;if(J!==typeof W)return!1;if(J!=="object")return!1;if(Z.constructor!==W.constructor)return!1;if(Array.isArray(Z))return bG(Z,W);return gG(Z,W)},bG=(Z,W)=>{let J=Z.length;if(J!==W.length)return!1;while(J--)if(!TW(Z[J],W[J]))return!1;return!0},gG=(Z,W)=>{let J=Object.keys(Z),K=J.length;if(Object.keys(W).length!==K)return!1;while(K--){let X=J[K];if(!Object.hasOwn(W,X))return!1;if(!TW(Z[X],W[X]))return!1}return!0};class Z0 extends I{constructor(Z,W,J){super();this.handlers=Z,this.dispatched_paths=W,this.next_dispatched_paths=J}}class G9 extends I{constructor(Z,W){super();this.path=Z,this.handler=W}}class Q9 extends I{constructor(Z){super();this.path=Z}}function F9(){return new Z0(lZ(),m,m)}function M7(Z){return new Z0(Z.handlers,Z.next_dispatched_paths,m)}function U7(Z,W,J){return t6(Z,J9(W,J))}function LJ(Z,W,J){let K=U7(Z.handlers,W,J);return new Z0(K,Z.dispatched_paths,Z.next_dispatched_paths)}function Y7(Z,W,J){return UZ(J,Z,(K,X)=>{if(X instanceof nZ){let V=X.name;return U7(K,W,V)}else return K})}function M9(Z,W,J,K){let X=y8(Z.handlers,W+W9+J);if(X instanceof H){let V=X[0],Q=s(K,V);if(Q instanceof H){let G=Q[0];return new G9(W,G)}else return new Q9(W)}else return new Q9(W)}function U9(Z,W){let J=P(W.path,Z.next_dispatched_paths),K=new Z0(Z.handlers,Z.dispatched_paths,J);if(W instanceof G9){let X=W.handler;return[K,new H(X)]}else return[K,new q(void 0)]}function EJ(Z,W,J,K){let X=M9(Z,W,J,K);return((V)=>{return U9(Z,V)})(X)}function xJ(Z,W){return X7(W,Z.dispatched_paths)}function I7(Z,W,J,K,X){return E0(Z,J9(J,K),C8(X,(V)=>{return new E8(V.prevent_default,V.stop_propagation,o(W)(V.message))}))}function U8(Z,W,J,K,X){let V=I7(Z.handlers,W,J,K,X);return new Z0(V,Z.dispatched_paths,Z.next_dispatched_paths)}function G7(Z,W,J,K){return UZ(K,Z,(X,V)=>{if(V instanceof nZ){let{name:Q,handler:G}=V;return I7(X,W,J,Q,G)}else return X})}function eW(Z,W){let J=V9(Z,o);if(V9(W,o))return Z;else if(J)return W;else return(X)=>{return Z(W(X))}}function F7(Z,W,J,K){while(!0){let X=Z,V=W,Q=J,G=K;if(G instanceof T)return X;else{let{head:F,tail:M}=G;Z=z7(X,V,Q,F),W=V,J=Q+1,K=M}}}function z7(Z,W,J,K){if(K instanceof aZ){let X=K.children,V=IW(W,J,K.key);return F7(Z,V,0,X)}else if(K instanceof uZ){let{attributes:X,children:V}=K,Q=IW(W,J,K.key),F=Y7(Z,Q,X);return F7(F,Q,0,V)}else if(K instanceof JW)return Z;else{let X=K.attributes,V=IW(W,J,K.key);return Y7(Z,V,X)}}function W0(Z,W,J,K){let X=z7(Z.handlers,W,J,K);return new Z0(X,Z.dispatched_paths,Z.next_dispatched_paths)}function Y9(Z,W,J,K,X){while(!0){let V=Z,Q=W,G=J,F=K,M=X;if(M instanceof T)return V;else{let{head:U,tail:z}=M;Z=H7(V,Q,G,F,U),W=Q,J=G,K=F+1,X=z}}}function H7(Z,W,J,K,X){if(X instanceof aZ){let V=X.children,Q=IW(J,K,X.key),G=eW(W,X.mapper);return Y9(Z,G,Q,0,V)}else if(X instanceof uZ){let{attributes:V,children:Q}=X,G=IW(J,K,X.key),F=eW(W,X.mapper),U=G7(Z,F,G,V);return Y9(U,F,G,0,Q)}else if(X instanceof JW)return Z;else{let V=X.attributes,Q=IW(J,K,X.key),G=eW(W,X.mapper);return G7(Z,G,Q,V)}}function J0(Z,W,J,K,X){let V=H7(Z.handlers,W,J,K,X);return new Z0(V,Z.dispatched_paths,Z.next_dispatched_paths)}function I9(Z){return J0(F9(),o,Y8,0,Z)}function N7(Z,W,J,K,X){let V=Y9(Z.handlers,W,J,K,X);return new Z0(V,Z.dispatched_paths,Z.next_dispatched_paths)}function bZ(Z,W,J){return F8("",o,"",Z,W,J,lZ(),!1,G8(Z,""))}function K0(Z,W,J,K){return F8("",o,Z,W,J,K,lZ(),!1,G8(W,Z))}function x(Z){return X9("",o,Z)}function cZ(){return X9("",o,"")}function z9(Z,W){let J=o(eW(o(W),Z.mapper));if(Z instanceof aZ){let{children:K,keyed_children:X}=Z;return new aZ(Z.kind,Z.key,J,o(K),o(X))}else if(Z instanceof uZ){let{attributes:K,children:X,keyed_children:V}=Z;return new uZ(Z.kind,Z.key,J,Z.namespace,Z.tag,o(K),o(X),o(V),Z.self_closing,Z.void)}else if(Z instanceof JW)return o(Z);else{let K=Z.attributes;return new D0(Z.kind,Z.key,J,Z.namespace,Z.tag,o(K),Z.inner_html)}}function gW(Z){return x(Z)}function f0(Z,W){return bZ("h1",Z,W)}function v8(Z,W){return bZ("h2",Z,W)}function f(Z,W){return bZ("div",Z,W)}function I8(Z,W){return bZ("li",Z,W)}function pZ(Z,W){return bZ("p",Z,W)}function D7(Z,W){return bZ("pre",Z,W)}function B7(Z,W){return bZ("ul",Z,W)}function y0(Z,W){return bZ("a",Z,W)}function IZ(Z,W){return bZ("span",Z,W)}function dZ(Z,W){return bZ("button",Z,W)}function H9(Z,W){return bZ("form",Z,W)}function z8(Z){return bZ("input",Z,m)}function kJ(Z,W){return bZ("label",Z,W)}class _8 extends I{constructor(Z,W,J,K){super();this.index=Z,this.removed=W,this.changes=J,this.children=K}}class P7 extends I{constructor(Z,W){super();this.kind=Z,this.content=W}}class R7 extends I{constructor(Z,W){super();this.kind=Z,this.inner_html=W}}class j7 extends I{constructor(Z,W,J){super();this.kind=Z,this.added=W,this.removed=J}}class O7 extends I{constructor(Z,W,J){super();this.kind=Z,this.key=W,this.before=J}}class T7 extends I{constructor(Z,W,J){super();this.kind=Z,this.index=W,this.with=J}}class A7 extends I{constructor(Z,W){super();this.kind=Z,this.index=W}}class S7 extends I{constructor(Z,W,J){super();this.kind=Z,this.children=W,this.before=J}}function N9(Z,W,J,K){return new _8(Z,W,J,K)}var D9=0;function C7(Z){return new P7(D9,Z)}var B9=1;function q7(Z){return new R7(B9,Z)}var P9=2;function R9(Z,W){return new j7(P9,Z,W)}var j9=3;function w7(Z,W){return new O7(j9,Z,W)}var O9=4;function L7(Z){return new A7(O9,Z)}var T9=5;function b0(Z,W){return new T7(T9,Z,W)}var A9=6;function S9(Z,W){return new S7(A9,Z,W)}class x7 extends I{constructor(Z,W,J,K,X,V,Q,G){super();this.kind=Z,this.open_shadow_root=W,this.will_adopt_styles=J,this.observed_attributes=K,this.observed_properties=X,this.requested_contexts=V,this.provided_contexts=Q,this.vdom=G}}class k7 extends I{constructor(Z,W){super();this.kind=Z,this.patch=W}}class f7 extends I{constructor(Z,W,J){super();this.kind=Z,this.name=W,this.data=J}}class y7 extends I{constructor(Z,W,J){super();this.kind=Z,this.key=W,this.value=J}}class fJ extends I{constructor(Z,W){super();this.kind=Z,this.messages=W}}class yJ extends I{constructor(Z,W,J){super();this.kind=Z,this.name=W,this.value=J}}class bJ extends I{constructor(Z,W,J){super();this.kind=Z,this.name=W,this.value=J}}class gJ extends I{constructor(Z,W,J,K){super();this.kind=Z,this.path=W,this.name=J,this.event=K}}class C9 extends I{constructor(Z,W,J){super();this.kind=Z,this.key=W,this.value=J}}var _G=0;function b7(Z,W,J,K,X,V,Q){return new x7(_G,Z,W,J,K,X,V,Q)}var $G=1;function q9(Z){return new k7($G,Z)}var mG=2;function g7(Z,W){return new f7(mG,Z,W)}var uG=3;function h7(Z,W){return new y7(uG,Z,W)}class hJ extends I{constructor(Z,W){super();this.patch=Z,this.events=W}}class $7 extends I{constructor(Z,W,J){super();this.added=Z,this.removed=W,this.events=J}}function cG(Z,W,J,K){if(J==="input"&&W==="")return xJ(Z,K);else if(J==="select"&&W==="")return xJ(Z,K);else if(J==="textarea"&&W==="")return xJ(Z,K);else return!1}function _7(Z,W,J,K,X,V,Q,G){while(!0){let F=Z,M=W,U=J,z=K,D=X,O=V,R=Q,C=G;if(D instanceof T)if(O instanceof T)return new $7(R,C,z);else{let j=O.head;if(j instanceof nZ){let E=j,A=O.tail,k=j.name,b=j.handler,B=P(E,R),S=U8(z,U,M,k,b);Z=F,W=M,J=U,K=S,X=D,V=A,Q=B,G=C}else{let E=j,A=O.tail,k=P(E,R);Z=F,W=M,J=U,K=z,X=D,V=A,Q=k,G=C}}else if(O instanceof T){let j=D.head;if(j instanceof nZ){let E=j,A=D.tail,k=j.name,b=P(E,C),B=LJ(z,M,k);Z=F,W=M,J=U,K=B,X=A,V=O,Q=R,G=b}else{let E=j,A=D.tail,k=P(E,C);Z=F,W=M,J=U,K=z,X=A,V=O,Q=R,G=k}}else{let{head:j,tail:E}=D,A=O.head,k=O.tail,b=SJ(j,A);if(b instanceof TZ)if(j instanceof nZ){let B=j.name,S=P(j,C),y=LJ(z,M,B);Z=F,W=M,J=U,K=y,X=E,V=O,Q=R,G=S}else{let B=P(j,C);Z=F,W=M,J=U,K=z,X=E,V=O,Q=R,G=B}else if(b instanceof AZ)if(j instanceof UW)if(A instanceof UW){let B,S=A.name;if(S==="value")B=F||j.value!==A.value;else if(S==="checked")B=F||j.value!==A.value;else if(S==="selected")B=F||j.value!==A.value;else B=j.value!==A.value;let y=B,v;if(y)v=P(A,R);else v=R;let _=v;Z=F,W=M,J=U,K=z,X=E,V=k,Q=_,G=C}else if(A instanceof nZ){let{name:B,handler:S}=A,y=P(A,R),v=P(j,C),_=U8(z,U,M,B,S);Z=F,W=M,J=U,K=_,X=E,V=k,Q=y,G=v}else{let B=P(A,R),S=P(j,C);Z=F,W=M,J=U,K=z,X=E,V=k,Q=B,G=S}else if(j instanceof L8)if(A instanceof L8){let B,S=A.name;if(S==="scrollLeft")B=!0;else if(S==="scrollRight")B=!0;else if(S==="value")B=F||!TW(j.value,A.value);else if(S==="checked")B=F||!TW(j.value,A.value);else if(S==="selected")B=F||!TW(j.value,A.value);else B=!TW(j.value,A.value);let y=B,v;if(y)v=P(A,R);else v=R;let _=v;Z=F,W=M,J=U,K=z,X=E,V=k,Q=_,G=C}else if(A instanceof nZ){let{name:B,handler:S}=A,y=P(A,R),v=P(j,C),_=U8(z,U,M,B,S);Z=F,W=M,J=U,K=_,X=E,V=k,Q=y,G=v}else{let B=P(A,R),S=P(j,C);Z=F,W=M,J=U,K=z,X=E,V=k,Q=B,G=S}else if(A instanceof nZ){let{name:B,handler:S}=A,y=j.prevent_default.kind!==A.prevent_default.kind||j.stop_propagation.kind!==A.stop_propagation.kind||j.debounce!==A.debounce||j.throttle!==A.throttle,v;if(y)v=P(A,R);else v=R;let _=v,u=U8(z,U,M,B,S);Z=F,W=M,J=U,K=u,X=E,V=k,Q=_,G=C}else{let B=j.name,S=P(A,R),y=P(j,C),v=LJ(z,M,B);Z=F,W=M,J=U,K=v,X=E,V=k,Q=S,G=y}else if(A instanceof nZ){let{name:B,handler:S}=A,y=P(A,R),v=U8(z,U,M,B,S);Z=F,W=M,J=U,K=v,X=D,V=k,Q=y,G=C}else{let B=P(A,R);Z=F,W=M,J=U,K=z,X=D,V=k,Q=B,G=C}}}}function w9(Z,W,J,K,X,V,Q,G,F,M,U,z,D,O){while(!0){let R=Z,C=W,j=J,E=K,A=X,k=V,b=Q,B=G,S=F,y=M,v=U,_=z,u=D,YZ=O;if(R instanceof T)if(j instanceof T)return new hJ(new _8(S,b,v,_),YZ);else{let sZ=N7(YZ,u,y,B,j),_Z=S9(j,B-k),FZ=P(_Z,v);return new hJ(new _8(S,b,FZ,_),sZ)}else if(j instanceof T){let{head:sZ,tail:_Z}=R,FZ;if(sZ.key===""||!b8(A,sZ.key))FZ=b+1;else FZ=b;let ZZ=FZ,XZ=W0(YZ,y,B,sZ);Z=_Z,W=C,J=j,K=E,X=A,V=k,Q=ZZ,G=B,F=S,M=y,U=v,z=_,D=u,O=XZ}else{let sZ=R.head,_Z=j.head;if(sZ.key!==_Z.key){let FZ=R.tail,zZ=j.tail,ZZ=y8(C,_Z.key);if(b8(E,sZ.key))if(ZZ instanceof H){let p=ZZ[0];if(b8(A,sZ.key))Z=FZ,W=C,J=j,K=E,X=A,V=k-1,Q=b,G=B,F=S,M=y,U=v,z=_,D=u,O=YZ;else{let VZ=B-k,i=P(w7(_Z.key,VZ),v),PZ=E0(A,_Z.key,void 0),QZ=k+1;Z=P(p,R),W=C,J=j,K=E,X=PZ,V=QZ,Q=b,G=B,F=S,M=y,U=i,z=_,D=u,O=YZ}}else{let p=B-k,KZ=J0(YZ,u,y,B,_Z),VZ=S9(Y([_Z]),p),i=P(VZ,v);Z=R,W=C,J=zZ,K=E,X=A,V=k+1,Q=b,G=B+1,F=S,M=y,U=i,z=_,D=u,O=KZ}else if(ZZ instanceof H){let p=B-k,KZ=P(L7(p),v),VZ=W0(YZ,y,B,sZ),i=k-1;Z=FZ,W=C,J=j,K=E,X=A,V=i,Q=b,G=B,F=S,M=y,U=KZ,z=_,D=u,O=VZ}else{let p=b0(B-k,_Z),KZ,i=W0(YZ,y,B,sZ);KZ=J0(i,u,y,B,_Z);let PZ=KZ;Z=FZ,W=C,J=zZ,K=E,X=A,V=k,Q=b,G=B+1,F=S,M=y,U=P(p,v),z=_,D=u,O=PZ}}else{let FZ=R.head;if(FZ instanceof aZ){let zZ=j.head;if(zZ instanceof aZ){let ZZ=FZ,XZ=R.tail,p=zZ,KZ=j.tail,VZ=eW(u,p.mapper),i=IW(y,B,p.key),PZ=w9(ZZ.children,ZZ.keyed_children,p.children,p.keyed_children,lZ(),0,0,0,B,i,m,m,VZ,YZ),QZ,jZ=PZ.patch;if(jZ.changes instanceof T)if(jZ.children instanceof T)if(jZ.removed===0)QZ=_;else QZ=P(PZ.patch,_);else QZ=P(PZ.patch,_);else QZ=P(PZ.patch,_);let wW=QZ;Z=XZ,W=C,J=KZ,K=E,X=A,V=k,Q=b,G=B+1,F=S,M=y,U=v,z=wW,D=u,O=PZ.events}else{let ZZ=FZ,XZ=R.tail,p=zZ,KZ=j.tail,VZ=b0(B-k,p),i,QZ=W0(YZ,y,B,ZZ);i=J0(QZ,u,y,B,p);let jZ=i;Z=XZ,W=C,J=KZ,K=E,X=A,V=k,Q=b,G=B+1,F=S,M=y,U=P(VZ,v),z=_,D=u,O=jZ}}else if(FZ instanceof uZ){let zZ=j.head;if(zZ instanceof uZ){let ZZ=FZ,XZ=zZ;if(ZZ.namespace===XZ.namespace&&ZZ.tag===XZ.tag){let p=R.tail,KZ=j.tail,VZ=eW(u,XZ.mapper),i=IW(y,B,XZ.key),PZ=cG(YZ,XZ.namespace,XZ.tag,i),QZ=_7(PZ,i,VZ,YZ,ZZ.attributes,XZ.attributes,m,m),jZ,uW,wW;jZ=QZ.added,uW=QZ.removed,wW=QZ.events;let O0;if(jZ instanceof T&&uW instanceof T)O0=m;else O0=Y([R9(jZ,uW)]);let d0=O0,s0=w9(ZZ.children,ZZ.keyed_children,XZ.children,XZ.keyed_children,lZ(),0,0,0,B,i,d0,m,VZ,wW),G0,T0=s0.patch;if(T0.changes instanceof T)if(T0.children instanceof T)if(T0.removed===0)G0=_;else G0=P(s0.patch,_);else G0=P(s0.patch,_);else G0=P(s0.patch,_);let hQ=G0;Z=p,W=C,J=KZ,K=E,X=A,V=k,Q=b,G=B+1,F=S,M=y,U=v,z=hQ,D=u,O=s0.events}else{let p=FZ,KZ=R.tail,VZ=zZ,i=j.tail,PZ=b0(B-k,VZ),QZ,uW=W0(YZ,y,B,p);QZ=J0(uW,u,y,B,VZ);let wW=QZ;Z=KZ,W=C,J=i,K=E,X=A,V=k,Q=b,G=B+1,F=S,M=y,U=P(PZ,v),z=_,D=u,O=wW}}else{let ZZ=FZ,XZ=R.tail,p=zZ,KZ=j.tail,VZ=b0(B-k,p),i,QZ=W0(YZ,y,B,ZZ);i=J0(QZ,u,y,B,p);let jZ=i;Z=XZ,W=C,J=KZ,K=E,X=A,V=k,Q=b,G=B+1,F=S,M=y,U=P(VZ,v),z=_,D=u,O=jZ}}else if(FZ instanceof JW){let zZ=j.head;if(zZ instanceof JW){let ZZ=FZ,XZ=zZ;if(ZZ.content===XZ.content){let p=R.tail,KZ=j.tail;Z=p,W=C,J=KZ,K=E,X=A,V=k,Q=b,G=B+1,F=S,M=y,U=v,z=_,D=u,O=YZ}else{let p=R.tail,KZ=zZ,VZ=j.tail,i=N9(B,0,Y([C7(KZ.content)]),m);Z=p,W=C,J=VZ,K=E,X=A,V=k,Q=b,G=B+1,F=S,M=y,U=v,z=P(i,_),D=u,O=YZ}}else{let ZZ=FZ,XZ=R.tail,p=zZ,KZ=j.tail,VZ=b0(B-k,p),i,QZ=W0(YZ,y,B,ZZ);i=J0(QZ,u,y,B,p);let jZ=i;Z=XZ,W=C,J=KZ,K=E,X=A,V=k,Q=b,G=B+1,F=S,M=y,U=P(VZ,v),z=_,D=u,O=jZ}}else{let zZ=j.head;if(zZ instanceof D0){let ZZ=FZ,XZ=R.tail,p=zZ,KZ=j.tail,VZ=eW(u,p.mapper),i=IW(y,B,p.key),PZ=_7(!1,i,VZ,YZ,ZZ.attributes,p.attributes,m,m),QZ,jZ,uW;QZ=PZ.added,jZ=PZ.removed,uW=PZ.events;let wW;if(QZ instanceof T&&jZ instanceof T)wW=m;else wW=Y([R9(QZ,jZ)]);let O0=wW,d0;if(ZZ.inner_html===p.inner_html)d0=O0;else d0=P(q7(p.inner_html),O0);let G0=d0,T0;if(G0 instanceof T)T0=_;else T0=P(N9(B,0,G0,Y([])),_);let CK=T0;Z=XZ,W=C,J=KZ,K=E,X=A,V=k,Q=b,G=B+1,F=S,M=y,U=v,z=CK,D=u,O=uW}else{let ZZ=FZ,XZ=R.tail,p=zZ,KZ=j.tail,VZ=b0(B-k,p),i,QZ=W0(YZ,y,B,ZZ);i=J0(QZ,u,y,B,p);let jZ=i;Z=XZ,W=C,J=KZ,K=E,X=A,V=k,Q=b,G=B+1,F=S,M=y,U=P(VZ,v),z=_,D=u,O=jZ}}}}}}function H8(Z,W,J){return w9(Y([W]),lZ(),Y([J]),lZ(),lZ(),0,0,0,0,Y8,m,m,o,M7(Z))}var{setTimeout:pG,clearTimeout:L9}=globalThis,dG=(Z,W)=>MW().createElementNS(Z,W),sG=(Z)=>MW().createTextNode(Z),rG=()=>MW().createDocumentFragment(),N8=(Z,W,J)=>Z.insertBefore(W,J),u7=_X?(Z,W,J)=>Z.moveBefore(W,J):N8,nG=(Z,W)=>Z.removeChild(W),iG=(Z,W)=>Z.getAttribute(W),c7=(Z,W,J)=>Z.setAttribute(W,J),lG=(Z,W)=>Z.removeAttribute(W),aG=(Z,W,J,K)=>Z.addEventListener(W,J,K),p7=(Z,W,J)=>Z.removeEventListener(W,J),oG=(Z,W)=>Z.innerHTML=W,tG=(Z,W)=>Z.data=W,X0=Symbol("lustre");class r7{constructor(Z,W,J,K){this.kind=Z,this.key=K,this.parent=W,this.children=[],this.node=J,this.handlers=new Map,this.throttles=new Map,this.debouncers=new Map}get parentNode(){return this.kind===OW?this.node.parentNode:this.node}}var V0=(Z,W,J,K,X)=>{let V=new r7(Z,W,J,X);return J[X0]=V,W?.children.splice(K,0,V),V},eG=(Z)=>{let W="";for(let J=Z[X0];J.parent;J=J.parent)if(J.key)W=`${h8}${J.key}${W}`;else{let K=J.parent.children.indexOf(J);W=`${h8}${K}${W}`}return W.slice(1)};class x9{#Z=null;#X;#J;#K=!1;constructor(Z,W,J,{exposeKeys:K=!1}={}){this.#Z=Z,this.#X=W,this.#J=J,this.#K=K}mount(Z){V0(x0,null,this.#Z,0,null),this.#P(this.#Z,null,this.#Z[X0],0,Z)}push(Z){this.#W.push({node:this.#Z[X0],patch:Z}),this.#V()}#W=[];#V(){let Z=this.#W;while(Z.length){let{node:W,patch:J}=Z.pop(),{children:K}=W,{changes:X,removed:V,children:Q}=J;if(g0(X,(G)=>this.#Q(W,G)),V)this.#I(W,K.length-V,V);g0(Q,(G)=>{let F=K[G.index|0];this.#W.push({node:F,patch:G})})}}#Q(Z,W){switch(W.kind){case D9:this.#A(Z,W);break;case B9:this.#R(Z,W);break;case P9:this.#H(Z,W);break;case j9:this.#M(Z,W);break;case O9:this.#D(Z,W);break;case T9:this.#U(Z,W);break;case A9:this.#G(Z,W);break}}#G(Z,{children:W,before:J}){let K=rG(),X=this.#F(Z,J);this.#B(K,null,Z,J|0,W),N8(Z.parentNode,K,X)}#U(Z,{index:W,with:J}){this.#I(Z,W|0,1);let K=this.#F(Z,W);this.#P(Z.parentNode,K,Z,W|0,J)}#F(Z,W){W=W|0;let{children:J}=Z,K=J.length;if(W<K)return J[W].node;let X=J[K-1];if(!X&&Z.kind!==OW)return null;if(!X)X=Z;while(X.kind===OW&&X.children.length)X=X.children[X.children.length-1];return X.node.nextSibling}#M(Z,{key:W,before:J}){J=J|0;let{children:K,parentNode:X}=Z,V=K[J].node,Q=K[J];for(let U=J+1;U<K.length;++U){let z=K[U];if(K[U]=Q,Q=z,z.key===W){K[J]=z;break}}let{kind:G,node:F,children:M}=Q;if(u7(X,F,V),G===OW)this.#Y(X,M,V)}#Y(Z,W,J){for(let K=0;K<W.length;++K){let{kind:X,node:V,children:Q}=W[K];if(u7(Z,V,J),X===OW)this.#Y(Z,Q,J)}}#D(Z,{index:W}){this.#I(Z,W,1)}#I(Z,W,J){let{children:K,parentNode:X}=Z,V=K.splice(W,J);for(let Q=0;Q<V.length;++Q){let{kind:G,node:F,children:M}=V[Q];if(nG(X,F),this.#z(V[Q]),G===OW)V.push(...M)}}#z(Z){let{debouncers:W,children:J}=Z;for(let{timeout:K}of W.values())if(K)L9(K);W.clear(),g0(J,(K)=>this.#z(K))}#H({node:Z,handlers:W,throttles:J,debouncers:K},{added:X,removed:V}){g0(V,({name:Q})=>{if(W.delete(Q))p7(Z,Q,E9),this.#N(J,Q,0),this.#N(K,Q,0);else lG(Z,Q),s7[Q]?.removed?.(Z,Q)}),g0(X,(Q)=>this.#T(Z,Q))}#A({node:Z},{content:W}){tG(Z,W??"")}#R({node:Z},{inner_html:W}){oG(Z,W??"")}#B(Z,W,J,K,X){g0(X,(V)=>this.#P(Z,W,J,K++,V))}#P(Z,W,J,K,X){switch(X.kind){case x0:{let V=this.#j(J,K,X);this.#B(V,null,V[X0],0,X.children),N8(Z,V,W);break}case M8:{let V=this.#O(J,K,X);N8(Z,V,W);break}case OW:{let V=this.#O(J,K,X);N8(Z,V,W),this.#B(Z,W,V[X0],0,X.children);break}case Q7:{let V=this.#j(J,K,X);this.#R({node:V},X),N8(Z,V,W);break}}}#j(Z,W,{kind:J,key:K,tag:X,namespace:V,attributes:Q}){let G=dG(V||OJ,X);if(V0(J,Z,G,W,K),this.#K&&K)c7(G,"data-lustre-key",K);return g0(Q,(F)=>this.#T(G,F)),G}#O(Z,W,{kind:J,key:K,content:X}){let V=sG(X??"");return V0(J,Z,V,W,K),V}#T(Z,W){let{debouncers:J,handlers:K,throttles:X}=Z[X0],{kind:V,name:Q,value:G,prevent_default:F,debounce:M,throttle:U}=W;switch(V){case p6:{let z=G??"";if(Q==="virtual:defaultValue"){Z.defaultValue=z;return}else if(Q==="virtual:defaultChecked"){Z.defaultChecked=!0;return}else if(Q==="virtual:defaultSelected"){Z.defaultSelected=!0;return}if(z!==iG(Z,Q))c7(Z,Q,z);s7[Q]?.added?.(Z,z);break}case d6:Z[Q]=G;break;case s6:{if(K.has(Q))p7(Z,Q,E9);let z=F.kind===r6;aG(Z,Q,E9,{passive:z}),this.#N(X,Q,U),this.#N(J,Q,M),K.set(Q,(D)=>this.#S(W,D));break}}}#N(Z,W,J){let K=Z.get(W);if(J>0)if(K)K.delay=J;else Z.set(W,{delay:J});else if(K){let{timeout:X}=K;if(X)L9(X);Z.delete(W)}}#S(Z,W){let{currentTarget:J,type:K}=W,{debouncers:X,throttles:V}=J[X0],Q=eG(J),{prevent_default:G,stop_propagation:F,include:M}=Z;if(G.kind===i6)W.preventDefault();if(F.kind===i6)W.stopPropagation();if(K==="submit")W.detail??={},W.detail.formData=[...new FormData(W.target,W.submitter).entries()];let U=this.#X(W,Q,K,M),z=V.get(K);if(z){let O=Date.now(),R=z.last||0;if(O>R+z.delay)z.last=O,z.lastEvent=W,this.#J(W,U)}let D=X.get(K);if(D)L9(D.timeout),D.timeout=pG(()=>{if(W===V.get(K)?.lastEvent)return;this.#J(W,U)},D.delay);if(!z&&!D)this.#J(W,U)}}var g0=(Z,W)=>{if(Array.isArray(Z))for(let J=0;J<Z.length;J++)W(Z[J]);else if(Z)for(Z;Z.head;Z=Z.tail)W(Z.head)},E9=(Z)=>{let{currentTarget:W,type:J}=Z;W[X0].handlers.get(J)(Z)},d7=(Z)=>{return{added(W){W[Z]=!0},removed(W){W[Z]=!1}}},Z5=(Z)=>{return{added(W,J){W[Z]=J}}},s7={checked:d7("checked"),selected:d7("selected"),value:Z5("value"),autofocus:{added(Z){queueMicrotask(()=>{Z.focus?.()})}},autoplay:{added(Z){try{Z.play?.()}catch(W){console.error(W)}}}};function W5(Z,W,J){while(!0){let K=Z,X=W,V=J;if(K instanceof T)return[X,l(V)];else{let Q=K.tail,G=K.head[0],F=K.head[1],M=V7(G,F),U;if(G==="")U=X;else U=E0(X,G,M);let z=U,D=P(M,V);Z=Q,W=z,J=D}}}function k9(Z){return W5(Z,lZ(),m)}function n7(Z,W,J){let K=k9(J),X,V;return X=K[0],V=K[1],F8("",o,"",Z,W,V,X,!1,G8(Z,""))}function i7(Z,W,J,K){let X=k9(K),V,Q;return V=X[0],Q=X[1],F8("",o,Z,W,J,Q,V,!1,G8(W,Z))}function l7(Z){let W=k9(Z),J,K;return J=W[0],K=W[1],K9("",o,K,J)}var a7=(Z)=>{let W=V0(x0,null,Z,0,null),J=0;for(let Q=Z.firstChild;Q;Q=Q.nextSibling)if(o7(Q))J+=1;if(J===0){let Q=MW().createTextNode("");return V0(M8,W,Q,0,null),Z.replaceChildren(Q),cZ()}if(J===1)return f9(W,Z).head[1];let K=MW().createTextNode(""),X=V0(OW,W,K,0,null),V=f9(X,Z);return Z.insertBefore(K,Z.firstChild),l7(V)},o7=(Z)=>{switch(Z.nodeType){case TJ:return!0;case u6:return!!Z.data;default:return!1}},J5=(Z,W,J,K)=>{if(!o7(W))return null;switch(W.nodeType){case TJ:{let X=V0(x0,Z,W,K,J),V=W.localName,Q=W.namespaceURI,G=!Q||Q===OJ;if(G&&K5.includes(V))X5(V,W);let F=V5(W),M=f9(X,W);return G?n7(V,F,M):i7(Q,V,F,M)}case u6:return V0(M8,Z,W,K,null),x(W.data);default:return null}},K5=["input","select","textarea"],X5=(Z,W)=>{let{value:J,checked:K}=W;if(Z==="input"&&W.type==="checkbox"&&!K)return;if(Z==="input"&&W.type==="radio"&&!K)return;if(W.type!=="checkbox"&&W.type!=="radio"&&!J)return;queueMicrotask(()=>{if(W.value=J,W.checked=K,W.dispatchEvent(new Event("input",{bubbles:!0})),W.dispatchEvent(new Event("change",{bubbles:!0})),MW().activeElement!==W)W.dispatchEvent(new Event("blur",{bubbles:!0}))})},f9=(Z,W)=>{let J=null,K=W.firstChild,X=null,V=0;while(K){let Q=K.nodeType===TJ?K.getAttribute("data-lustre-key"):null;if(Q!=null)K.removeAttribute("data-lustre-key");let G=J5(Z,K,Q,V),F=K.nextSibling;if(G){let M=new tZ([Q??"",G],null);if(X)X=X.tail=M;else X=J=M;V+=1}else W.removeChild(K);K=F}if(!X)return m;return X.tail=m,J},V5=(Z)=>{let W=Z.attributes.length,J=m;while(W-- >0){let K=Z.attributes[W];if(K.name==="xmlns")continue;J=new tZ(Q5(K),J)}return J},Q5=(Z)=>{let{localName:W,value:J}=Z;return w(W,J)};var B0=()=>!!MW();class vJ{constructor(Z,[W,J],K,X){this.root=Z,this.#Z=W,this.#X=K,this.#J=X,this.root.addEventListener("context-request",(G)=>{if(!(G.context&&G.callback))return;if(!this.#Q.has(G.context))return;G.stopImmediatePropagation();let F=this.#Q.get(G.context);if(G.subscribe){let M=()=>{F.subscribers=F.subscribers.filter((U)=>U!==G.callback)};F.subscribers.push([G.callback,M]),G.callback(F.value,M)}else G.callback(F.value)});let V=(G,F,M)=>M9(this.#W,F,M,G),Q=(G,F)=>{let[M,U]=U9(this.#W,F);if(this.#W=M,U.isOk()){let z=U[0];if(z.stop_propagation)G.stopPropagation();if(z.prevent_default)G.preventDefault();this.dispatch(z.message,!1)}};this.#V=new x9(this.root,V,Q),this.#K=a7(this.root),this.#W=F9(),this.#z(J),this.#H()}root=null;dispatch(Z,W=!1){if(this.#G)this.#U.push(Z);else{let[J,K]=this.#J(this.#Z,Z);this.#Z=J,this.#I(K,W)}}emit(Z,W){(this.root.host??this.root).dispatchEvent(new CustomEvent(Z,{detail:W,bubbles:!0,composed:!0}))}provide(Z,W){if(!this.#Q.has(Z))this.#Q.set(Z,{value:W,subscribers:[]});else{let J=this.#Q.get(Z);if(TW(J.value,W))return;J.value=W;for(let K=J.subscribers.length-1;K>=0;K--){let[X,V]=J.subscribers[K];if(!X){J.subscribers.splice(K,1);continue}X(W,V)}}}#Z;#X;#J;#K;#W;#V;#Q=new Map;#G=!1;#U=[];#F=m;#M=m;#Y=null;#D={dispatch:(Z)=>this.dispatch(Z),emit:(Z,W)=>this.emit(Z,W),select:()=>{},root:()=>this.root,provide:(Z,W)=>this.provide(Z,W)};#I(Z,W=!1){if(this.#z(Z),!this.#Y)if(W)this.#Y="sync",queueMicrotask(()=>this.#H());else this.#Y=requestAnimationFrame(()=>this.#H())}#z(Z){this.#G=!0;while(!0){for(let J=Z.synchronous;J.tail;J=J.tail)J.head(this.#D);if(this.#F=e7(this.#F,Z.before_paint),this.#M=e7(this.#M,Z.after_paint),!this.#U.length)break;let W=this.#U.shift();[this.#Z,Z]=this.#J(this.#Z,W)}this.#G=!1}#H(){this.#Y=null;let Z=this.#X(this.#Z),{patch:W,events:J}=H8(this.#W,this.#K,Z);if(this.#W=J,this.#K=Z,this.#V.push(W),this.#F instanceof tZ){let K=t7(this.#F);this.#F=m,queueMicrotask(()=>{this.#I(K,!0)})}if(this.#M instanceof tZ){let K=t7(this.#M);this.#M=m,requestAnimationFrame(()=>{this.#I(K,!0)})}}}function t7(Z){return{synchronous:Z,after_paint:m,before_paint:m}}function e7(Z,W){if(Z instanceof T)return W;else if(W instanceof T)return Z;else return SZ(Z,W)}class b9 extends I{constructor(Z){super();this.message=Z}}class g9 extends I{constructor(Z){super();this.callback=Z}}class h9 extends I{constructor(Z){super();this.callback=Z}}class P0 extends I{constructor(Z){super();this.message=Z}}class h0 extends I{constructor(Z,W){super();this.name=Z,this.data=W}}class _J extends I{constructor(Z,W){super();this.key=Z,this.value=W}}class v0 extends I{}class JV extends I{constructor(Z,W,J,K,X,V,Q,G,F,M){super();this.open_shadow_root=Z,this.adopt_styles=W,this.delegates_focus=J,this.attributes=K,this.properties=X,this.contexts=V,this.is_form_associated=Q,this.on_form_autofill=G,this.on_form_reset=F,this.on_form_restore=M}}function KV(Z){let W=new JV(!0,!0,!1,m,m,m,!1,AJ,AJ,AJ);return UZ(Z,W,(J,K)=>{return K.apply(J)})}class VV{#Z;constructor(Z,[W,J],K,X){this.#Z=new vJ(Z,[W,J],X,K)}send(Z){switch(Z.constructor){case P0:{this.dispatch(Z.message,!1);break}case h0:{this.emit(Z.name,Z.data);break}case v0:break}}dispatch(Z){this.#Z.dispatch(Z)}emit(Z,W){this.#Z.emit(Z,W)}}var QV=({init:Z,update:W,view:J},K,X)=>{if(!B0())return new q(new $8);let V=K instanceof HTMLElement?K:MW().querySelector(K);if(!V)return new q(new v9(K));return new H(new VV(V,Z(X),W,J))};class Y5{#Z;#X;#J;#K;#W;#V;#Q=MZ();#G=new Set;constructor([Z,W],J,K,X){this.#Z=Z,this.#X=J,this.#J=K,this.#K=X,this.#W=this.#J(this.#Z),this.#V=I9(this.#W),this.#Y(W)}send(Z){switch(Z.constructor){case b9:{let{message:W}=Z,J=this.#U(W),K=H8(this.#V,this.#W,J);this.#W=J,this.#V=K.events,this.broadcast(q9(K.patch));return}case g9:{let{callback:W}=Z;this.#G.add(W),W(b7(this.#K.open_shadow_root,this.#K.adopt_styles,pW(this.#K.attributes),pW(this.#K.properties),pW(this.#K.contexts),this.#Q,this.#W));return}case h9:{let{callback:W}=Z;this.#G.delete(W);return}case P0:{let{message:W}=Z,[J,K]=this.#X(this.#Z,W),X=this.#J(J),V=H8(this.#V,this.#W,X);this.#Y(K),this.#Z=J,this.#W=X,this.#V=V.events,this.broadcast(q9(V.patch));return}case h0:{let{name:W,data:J}=Z;this.broadcast(g7(W,J));return}case _J:{let{key:W,value:J}=Z,K=r(this.#Q,W);if(K.isOk()&&TW(K[0],J))return;this.#Q=OZ(this.#Q,W,J),this.broadcast(h7(W,J));return}case v0:{this.#Z=null,this.#X=null,this.#J=null,this.#K=null,this.#W=null,this.#V=null,this.#Q=null,this.#G.clear();return}default:return}}broadcast(Z){for(let W of this.#G)W(Z)}#U(Z){switch(Z.constructor){case fJ:{let{messages:W}=Z,J=this.#Z,K=iZ();for(let X=W;X.head;X=X.tail){let V=this.#U(X.head);if(V instanceof H){J=V[0][0],K=jW(LZ.fromArray([K,V[0][1]]));break}}return this.#Y(K),this.#Z=J,this.#J(this.#Z)}case yJ:{let{name:W,value:J}=Z,K=this.#F(W,J);if(K instanceof q)return this.#W;else{let[X,V]=this.#X(this.#Z,K[0]);return this.#Y(V),this.#Z=X,this.#J(this.#Z)}}case bJ:{let{name:W,value:J}=Z,K=this.#M(W,J);if(K instanceof q)return this.#W;else{let[X,V]=this.#X(this.#Z,K[0]);return this.#Y(V),this.#Z=X,this.#J(this.#Z)}}case gJ:{let{path:W,name:J,event:K}=Z,[X,V]=EJ(this.#V,W,J,K);if(this.#V=X,V instanceof q)return this.#W;else{let[Q,G]=this.#X(this.#Z,V[0].message);return this.#Y(G),this.#Z=Q,this.#J(this.#Z)}}case C9:{let{key:W,value:J}=Z,K=r(this.#K.contexts,W);if(K instanceof q)return this.#W;if(K=s(J,K[0]),K instanceof q)return this.#W;let[X,V]=this.#X(this.#Z,K[0]);return this.#Y(V),this.#Z=X,this.#J(this.#Z)}}}#F(Z,W){let J=r(this.#K.attributes,Z);switch(J.constructor){case H:return J[0](W);case q:return new q(void 0)}}#M(Z,W){let J=r(this.#K.properties,Z);switch(J.constructor){case H:return J[0](W);case q:return new q(void 0)}}#Y(Z){let W=(Q)=>this.send(new P0(Q)),J=(Q,G)=>this.send(new h0(Q,G)),K=()=>{return},X=()=>{return},V=(Q,G)=>this.send(new _J(Q,G));globalThis.queueMicrotask(()=>{Z7(Z,W,J,K,X,V)})}}class YV extends I{constructor(Z,W,J,K){super();this.init=Z,this.update=W,this.view=J,this.config=K}}class v9 extends I{constructor(Z){super();this.selector=Z}}class $8 extends I{}function GV(Z,W,J){return new YV(Z,W,J,KV(m))}function FV(Z,W,J){return V8(!B0(),new q(new $8),()=>{return QV(Z,W,J)})}var M5={handle_external_links:!1,handle_internal_links:!0},zV=globalThis?.window?.location?.href,m9=()=>{if(!zV)return new q(void 0);else return new H($9(new URL(zV)))},u9=(Z,W=M5)=>{document.addEventListener("click",(J)=>{let K=NV(J.target);if(!K)return;try{let X=new URL(K.href),V=$9(X),Q=X.host!==window.location.host||K.target==="_blank";if(!W.handle_external_links&&Q)return;if(!W.handle_internal_links&&!Q)return;if(J.preventDefault(),!Q)window.history.pushState({},"",K.href),window.requestAnimationFrame(()=>{if(X.hash)document.getElementById(X.hash.slice(1))?.scrollIntoView();else window.scrollTo(0,0)});return Z(V)}catch{return}}),window.addEventListener("popstate",(J)=>{J.preventDefault();let K=new URL(window.location.href),X=$9(K);window.requestAnimationFrame(()=>{if(K.hash)document.getElementById(K.hash.slice(1))?.scrollIntoView();else window.scrollTo(0,0)}),Z(X)}),window.addEventListener("modem-push",({detail:J})=>{Z(J)}),window.addEventListener("modem-replace",({detail:J})=>{Z(J)})},HV=(Z)=>{window.history.pushState({},"",PJ(Z)),window.requestAnimationFrame(()=>{if(Z.fragment[0])document.getElementById(Z.fragment[0])?.scrollIntoView()}),window.dispatchEvent(new CustomEvent("modem-push",{detail:Z}))};var NV=(Z)=>{if(!Z||Z.tagName==="BODY")return null;else if(Z.tagName==="A")return Z;else return NV(Z.parentElement)},$9=(Z)=>{return new d(Z.protocol?new L(Z.protocol.slice(0,-1)):new g,new g,Z.hostname?new L(Z.hostname):new g,Z.port?new L(Number(Z.port)):new g,Z.pathname,Z.search?new L(Z.search.slice(1)):new g,Z.hash?new L(Z.hash.slice(1)):new g)};function BV(Z){return aW((W)=>{return V8(!B0(),void 0,()=>{return u9((J)=>{let X=Z(J);return W(X)})})})}function DV(Z){if(Z==="")return new g;else return new L(Z)}var $J=new d(new g,new g,new g,new g,"",new g,new g);function mJ(Z,W,J){return aW((K)=>{return V8(!B0(),void 0,()=>{return HV(new d($J.scheme,$J.userinfo,$J.host,$J.port,Z,U6(W,DV),U6(J,DV)))})})}class PV extends I{constructor(Z,W){super();this.query=Z,this.module_path=W}}class c9 extends I{constructor(Z){super();this.queries=Z}}function RV(){return new c9(MZ())}function vW(Z,W,J,K){let X=new PV(J,K);return new c9(OZ(Z.queries,W,X))}function p9(Z,W){return r(Z.queries,W)}class uJ extends I{}class cJ extends I{}class jV extends I{}class OV extends I{}class TV extends I{}class AV extends I{}class SV extends I{}class CV extends I{}class qV extends I{}class s9 extends I{}class pJ extends I{}function wV(Z){if(Z instanceof uJ)return"GET";else if(Z instanceof cJ)return"POST";else if(Z instanceof jV)return"HEAD";else if(Z instanceof OV)return"PUT";else if(Z instanceof TV)return"DELETE";else if(Z instanceof AV)return"TRACE";else if(Z instanceof SV)return"CONNECT";else if(Z instanceof CV)return"OPTIONS";else if(Z instanceof qV)return"PATCH";else return Z[0]}function LV(Z){if(Z instanceof s9)return"http";else return"https"}function EV(Z){let W=FW(Z);if(W==="http")return new H(new s9);else if(W==="https")return new H(new pJ);else return new q(void 0)}class u8 extends I{constructor(Z,W,J,K,X,V,Q,G){super();this.method=Z,this.headers=W,this.body=J,this.scheme=K,this.host=X,this.port=V,this.path=Q,this.query=G}}function kV(Z){return new d(new L(LV(Z.scheme)),new g,new L(Z.host),Z.port,Z.path,Z.query,new g)}function N5(Z){return rZ((()=>{let W=Z.scheme,J=gK(W,"");return EV(J)})(),(W)=>{return rZ((()=>{let J=Z.host;return M6(J,void 0)})(),(J)=>{let K=new u8(new uJ,Y([]),"",W,J,Z.port,Z.path,Z.query);return new H(K)})})}function r9(Z,W,J){let K=w6(Z.headers,FW(W),J);return new u8(Z.method,K,Z.body,Z.scheme,Z.host,Z.port,Z.path,Z.query)}function fV(Z,W){return new u8(Z.method,Z.headers,W,Z.scheme,Z.host,Z.port,Z.path,Z.query)}function yV(Z,W){return new u8(W,Z.headers,Z.body,Z.scheme,Z.host,Z.port,Z.path,Z.query)}function bV(Z){let J=h6(Z);return rZ(J,N5)}class dJ extends I{constructor(Z,W,J){super();this.status=Z,this.headers=W,this.body=J}}class _0{constructor(Z){this.promise=Z}static wrap(Z){return Z instanceof Promise?new _0(Z):Z}static unwrap(Z){return Z instanceof _0?Z.promise:Z}}function _W(Z){return Promise.resolve(_0.wrap(Z))}function sJ(Z,W){return Z.then((J)=>W(_0.unwrap(J)))}function rJ(Z,W){return Z.then((J)=>_0.wrap(W(_0.unwrap(J))))}function l9(Z){return new dJ(Z.status,LZ.fromArray([...Z.headers]),Z)}function R5(Z){let W=PJ(kV(Z)),J=wV(Z.method).toUpperCase(),K={headers:j5(Z.headers),method:J};return[W,K]}function a9(Z){let[W,J]=R5(Z);if(J.method!=="GET"&&J.method!=="HEAD")J.body=Z.body;return new globalThis.Request(W,J)}function j5(Z){let W=new globalThis.Headers;for(let[J,K]of Z)W.append(J.toLowerCase(),K);return W}async function nJ(Z){let W;try{W=await Z.body.text()}catch(J){return new q(new o9)}return new H(Z.withFields({body:W}))}class iJ extends I{constructor(Z){super();this[0]=Z}}class o9 extends I{}class gV extends I{constructor(Z,W){super();this.endpoint=Z,this.headers=W}}function t9(Z,W){return new gV(Z,W)}function zW(Z,W,J){let K=h(Y([["query",a(W)],["variables",J]]));return rZ((()=>{let X=bV(Z.endpoint);return Z8(X,(V)=>{return"Invalid endpoint URL"})})(),(X)=>{let V,G=yV(X,new cJ),F=fV(G,nW(K));V=r9(F,"content-type","application/json");let M=V,U=UZ(Z.headers,M,(z,D)=>{return r9(z,D[0],D[1])});return new H(U)})}function wZ(Z,W){return rZ((()=>{let J=xW(Z,NZ);return Z8(J,(K)=>{return"Failed to decode JSON response"})})(),(J)=>{let K=$("data",W,(V)=>{return t(V)}),X=s(J,K);return Z8(X,(V)=>{return"Failed to decode response data: "+DX(V)+". Response body: "+Z})})}async function e9(Z){try{let W=a9(Z),J=new Request(W,{credentials:"include"}),K=await fetch(J),X=l9(K);return new H(X)}catch(W){return new q(new iJ(W.toString()))}}var mV="src/squall_cache.gleam";class AW extends I{}class SW extends I{constructor(Z){super();this[0]=Z}}class CW extends I{constructor(Z){super();this[0]=Z}}class lJ extends I{}class uV extends I{}class ZK extends I{}class p8 extends I{constructor(Z,W,J){super();this.data=Z,this.timestamp=W,this.status=J}}class oZ extends I{constructor(Z,W,J,K,X,V,Q,G){super();this.entities=Z,this.optimistic_entities=W,this.optimistic_mutations=J,this.queries=K,this.pending_fetches=X,this.get_headers=V,this.mutation_counter=Q,this.endpoint=G}}function cV(Z){return new oZ(MZ(),MZ(),MZ(),MZ(),w8(),()=>{return Y([])},0,Z)}function aJ(Z,W){return Z+":"+nW(W)}function hV(Z){let W=FJ(Z);if(W instanceof H){let J=W[0][0],K=W[0][1];return MJ(J)+K}else return Z}function WK(Z){let J=l(Z),K=C6(J,(X)=>{if(X==="data")return new q(void 0);else if(X==="results")return new q(void 0);else if(X==="edges")return new q(void 0);else if(X==="node")return new q(void 0);else if(UJ(X,"s")){let Q=GJ(X),G=NX(X,0,Q-1);return new H(hV(G))}else return new H(hV(X))});return W8(K,"Entity")}function C5(Z){if(r(Z,"node")instanceof H)return!0;else return!1}function q5(Z){let W=s(Z,PW(NZ));if(W instanceof H){let J=W[0];if(J instanceof T)return!1;else{let K=J.head,X=s(K,rW(c,NZ));if(X instanceof H){let V=X[0];return C5(V)}else return!1}}else return!1}function w5(Z,W,J){let K=aJ(W,J),X=r(Z.queries,K);if(X instanceof H){let V=X[0],Q=new p8(V.data,V.timestamp,new ZK),G=OZ(Z.queries,K,Q);return new oZ(Z.entities,Z.optimistic_entities,Z.optimistic_mutations,G,Z.pending_fetches,Z.get_headers,Z.mutation_counter,Z.endpoint)}else{let V=new p8("",0,new ZK),Q=OZ(Z.queries,K,V);return new oZ(Z.entities,Z.optimistic_entities,Z.optimistic_mutations,Q,Z.pending_fetches,Z.get_headers,Z.mutation_counter,Z.endpoint)}}function R0(Z,W,J){let K=aJ(W,J),X=dW(Z.queries,K);return new oZ(Z.entities,Z.optimistic_entities,Z.optimistic_mutations,X,Z.pending_fetches,Z.get_headers,Z.mutation_counter,Z.endpoint)}function L5(Z,W,J,K){let X,V=r(Z.optimistic_entities,J);if(V instanceof H){let F=V[0];X=new L(F)}else{let F=r(Z.entities,J);if(F instanceof H){let M=F[0];X=new L(M)}else X=new g}let G=K(X);return new oZ(Z.entities,OZ(Z.optimistic_entities,J,G),OZ(Z.optimistic_mutations,W,J),Z.queries,Z.pending_fetches,Z.get_headers,Z.mutation_counter,Z.endpoint)}function pV(Z,W){let J=r(Z.optimistic_mutations,W);if(J instanceof H){let K=J[0];return new oZ(Z.entities,dW(Z.optimistic_entities,K),dW(Z.optimistic_mutations,W),Z.queries,Z.pending_fetches,Z.get_headers,Z.mutation_counter,Z.endpoint)}else return Z}function dV(Z){return!S6(Z.optimistic_mutations)}function D8(Z){let W=s(Z,rW(c,NZ));if(W instanceof H){let K=W[0],X=U0(K),V=HZ(X,(Q)=>{let G,F;return G=Q[0],F=Q[1],[G,D8(F)]});return h(V)}else{let J=s(Z,PW(NZ));if(J instanceof H){let K=J[0];return kW(K,D8)}else{let K=s(Z,c);if(K instanceof H){let X=K[0];return a(X)}else{let X=s(Z,mZ);if(X instanceof H){let V=X[0];return WW(V)}else{let V=s(Z,eK);if(V instanceof H){let Q=V[0];return LX(Q)}else{let Q=s(Z,BW);if(Q instanceof H){let G=Q[0];return iW(G)}else return DJ()}}}}}}function E5(Z,W){let J=nW(Z),K=nW(W),X=xW(J,NZ),V=xW(K,NZ);if(X instanceof H&&V instanceof H){let Q=X[0],G=V[0],F=s(Q,rW(c,NZ)),M=s(G,rW(c,NZ));if(F instanceof H&&M instanceof H){let U=F[0],z=M[0],D,O=SZ(pW(U),pW(z));D=rK(O);let C=l0(D,(j)=>{let E,A=r(z,j);if(A instanceof H)E=A;else E=r(U,j);let k=E;if(k instanceof H){let b=k[0];return new H([j,D8(b)])}else return new q(void 0)});return h(C)}else return W}else return W}function Q0(Z,W){return n0(W,Z,(J,K,X)=>{let V=r(J,K);if(V instanceof H){let Q=V[0],G=E5(Q,X);return OZ(J,K,G)}else return OZ(J,K,X)})}function vV(Z){let W=IJ(Z,":");if(W instanceof H){let J=W[0][0],K=W[0][1],X=xW(K,NZ);if(X instanceof H){let V=X[0];return new H([J,D8(V)])}else return new H([J,DJ()])}else return new q(void 0)}function x5(Z,W,J,K,X,V,Q){let G=p9(W,J);if(G instanceof H){let F=G[0];return aW((M)=>{let U=Z.get_headers(),z=t9(Z.endpoint,U),D=zW(z,F.query,K),O;if(D instanceof H)O=D[0];else throw F0("let_assert",mV,"squall_cache",685,"create_mutation_effect","Pattern match failed, no pattern matched the value.",{value:D,start:23172,end:23246,pattern_start:23183,pattern_end:23190});let R,C=e9(O);R=rJ(C,(E)=>{if(E instanceof H){let A=E[0],k=nJ(A);return sJ(k,(b)=>{if(b instanceof H){let B=b[0],S=V(B.body);if(S instanceof H){let y=S[0];return M(Q(X,new H(y),B.body)),_W(void 0)}else{let y=S[0];return M(Q(X,new q("Parse error: "+y),B.body)),_W(void 0)}}else return M(Q(X,new q("Failed to read response"),"")),_W(void 0)})}else return M(Q(X,new q("Failed to fetch"),"")),_W(void 0)});let j=R;return})}else return aW((F)=>{F(Q(X,new q("Query not found in registry"),""));return})}function sV(Z,W,J,K,X,V,Q,G){let F="mutation-"+qZ(Z.mutation_counter),M=L5(Z,F,X,V),U=new oZ(M.entities,M.optimistic_entities,M.optimistic_mutations,M.queries,M.pending_fetches,M.get_headers,Z.mutation_counter+1,M.endpoint),z=x5(U,W,J,K,F,Q,G);return[U,F,z]}function k5(Z,W,J,K,X){return aW((V)=>{let Q=Z.get_headers(),G=t9(Z.endpoint,Q),F=zW(G,W,K),M;if(F instanceof H)M=F[0];else throw F0("let_assert",mV,"squall_cache",974,"create_fetch_effect","Pattern match failed, no pattern matched the value.",{value:F,start:32602,end:32671,pattern_start:32613,pattern_end:32620});let U,z=e9(M);U=rJ(z,(O)=>{if(O instanceof H){let R=O[0],C=nJ(R);return sJ(C,(j)=>{if(j instanceof H){let E=j[0];return V(X(J,K,new H(E.body))),_W(void 0)}else return V(X(J,K,new q("Failed to read response"))),_W(void 0)})}else return V(X(J,K,new q("Failed to fetch"))),_W(void 0)});let D=U;return})}function qW(Z,W,J,K){let X=vX(Z.pending_fetches),V=l0(X,(F)=>{let M=vV(F);if(M instanceof H){let U=M[0][0],z=M[0][1],D=p9(W,U);if(D instanceof H){let O=D[0];return new H(k5(Z,O.query,U,z,J))}else return new q(void 0)}else return new q(void 0)}),Q=UZ(X,Z,(F,M)=>{let U=vV(M);if(U instanceof H){let z=U[0][0],D=U[0][1];return w5(F,z,D)}else return F});return[new oZ(Q.entities,Q.optimistic_entities,Q.optimistic_mutations,Q.queries,w8(),Q.get_headers,Q.mutation_counter,Q.endpoint),V]}function _V(Z,W,J){let K=U0(Z),X=HZ(K,(V)=>{let Q,G;return Q=V[0],G=V[1],[Q,JK(G,W,J)]});return h(X)}function JK(Z,W,J){let K=s(Z,rW(c,NZ));if(K instanceof H){let X=K[0],V=r(X,"__ref");if(V instanceof H){let Q=V[0],G=s(Q,c);if(G instanceof H){let F=G[0],M=r(W,F);if(M instanceof H)return M[0];else{let U=r(J,F);if(U instanceof H)return U[0];else return h(Y([["__ref",a(F)]]))}}else return _V(X,W,J)}else return _V(X,W,J)}else{let X=s(Z,PW(NZ));if(X instanceof H){let V=X[0];return kW(V,(Q)=>{return JK(Q,W,J)})}else return D8(Z)}}function $V(Z,W,J){let K=xW(Z,NZ);if(K instanceof H){let X=K[0],V=JK(X,W,J);return nW(V)}else return Z}function e(Z,W,J,K){let X=aJ(W,J),V=r(Z.queries,X);if(V instanceof H){let Q=V[0],G=Q.status;if(G instanceof lJ){let F=$V(Q.data,Z.optimistic_entities,Z.entities),M=K(F);if(M instanceof H){let U=M[0];return[Z,new CW(U)]}else{let U=M[0];return[Z,new SW("Parse error: "+U)]}}else if(G instanceof uV){let F=$V(Q.data,Z.optimistic_entities,Z.entities),M=K(F);if(M instanceof H){let U=M[0];return[Z,new CW(U)]}else{let U=M[0];return[Z,new SW("Parse error: "+U)]}}else return[Z,new AW]}else return[new oZ(Z.entities,Z.optimistic_entities,Z.optimistic_mutations,Z.queries,$6(Z.pending_fetches,X),Z.get_headers,Z.mutation_counter,Z.endpoint),new AW]}function f5(Z,W){let J=UZ(Z,[MZ(),Y([]),w8()],(V,Q)=>{let G,F,M;G=V[0],F=V[1],M=V[2];let U=s(Q,rW(c,NZ));if(U instanceof H){let z=U[0],D=r(z,"node");if(D instanceof H){let O=D[0],R,C=s(O,rW(c,NZ));if(C instanceof H){let E=C[0],A=r(E,"id");if(A instanceof H){let k=A[0],b=s(k,c);if(b instanceof H){let B=b[0],S,y=r(E,"__typename");if(y instanceof H){let _=y[0],u=s(_,c);if(u instanceof H)S=u[0];else S="Node"}else S=WK(SZ(W,Y(["node"])));R=new L(S+":"+B)}else R=new g}else R=new g}else R=new g;let j=R;if(j instanceof L){let E=j[0];if(hX(M,E))return V;else{let k=c8(z,W),b,B;return b=k[0],B=k[1],[Q0(G,b),SZ(F,Y([B])),$6(M,E)]}}else{let E=c8(z,W),A,k;return A=E[0],k=E[1],[Q0(G,A),SZ(F,Y([k])),M]}}else{let O=c8(z,W),R,C;return R=O[0],C=O[1],[Q0(G,R),SZ(F,Y([C])),M]}}else{let z=d8(Q,W),D,O;return D=z[0],O=z[1],[Q0(G,D),SZ(F,Y([O])),M]}}),K,X;return K=J[0],X=J[1],[K,kW(X,(V)=>{return V})]}function d8(Z,W){let J=s(Z,rW(c,NZ));if(J instanceof H){let K=J[0],X=r(K,"id");if(X instanceof H){let V=X[0],Q=s(V,c);if(Q instanceof H){let G=Q[0],F,M=r(K,"__typename");if(M instanceof H){let b=M[0],B=s(b,c);if(B instanceof H)F=B[0];else F=WK(W)}else F=WK(W);let z=F+":"+G,D,O=U0(K);D=HZ(O,(b)=>{let B,S;B=b[0],S=b[1];let y=SZ(W,Y([B])),v=d8(S,y),_,u;return _=v[0],u=v[1],[B,_,u]});let R=D,C=UZ(R,MZ(),(b,B)=>{let S;return S=B[1],Q0(b,S)}),j,E=HZ(R,(b)=>{let B,S;return B=b[0],S=b[2],[B,S]});return j=h(E),[OZ(C,z,j),h(Y([["__ref",a(z)]]))]}else return c8(K,W)}else return c8(K,W)}else{let K=s(Z,PW(NZ));if(K instanceof H){let X=K[0];if(q5(Z))return f5(X,W);else{let Q=HZ(X,(M)=>{return d8(M,W)}),G=UZ(Q,MZ(),(M,U)=>{let z;return z=U[0],Q0(M,z)}),F=HZ(Q,(M)=>{let U;return U=M[1],U});return[G,kW(F,(M)=>{return M})]}}else return[MZ(),D8(Z)]}}function rV(Z){return d8(Z,Y([]))}function nV(Z,W,J,K,X){let V=aJ(W,J),Q=xW(K,NZ);if(Q instanceof H){let G=Q[0],F=rV(G),M,U;M=F[0],U=F[1];let z=Q0(Z.entities,M),D=nW(U),O=new p8(D,X,new lJ),R=OZ(Z.queries,V,O);return new oZ(z,Z.optimistic_entities,Z.optimistic_mutations,R,Z.pending_fetches,Z.get_headers,Z.mutation_counter,Z.endpoint)}else{let G=new p8(K,X,new lJ),F=OZ(Z.queries,V,G);return new oZ(Z.entities,Z.optimistic_entities,Z.optimistic_mutations,F,Z.pending_fetches,Z.get_headers,Z.mutation_counter,Z.endpoint)}}function c8(Z,W){let J,K=U0(Z);J=HZ(K,(M)=>{let U,z;U=M[0],z=M[1];let D=SZ(W,Y([U])),O=d8(z,D),R,C;return R=O[0],C=O[1],[U,R,C]});let X=J,V=UZ(X,MZ(),(M,U)=>{let z;return z=U[1],Q0(M,z)}),Q,G=HZ(X,(M)=>{let U,z;return U=M[0],z=M[2],[U,z]});return Q=h(G),[V,Q]}function iV(Z,W,J){let K=r(Z.optimistic_mutations,W);if(K instanceof H){let X=K[0],V=xW(J,NZ);if(V instanceof H){let Q=V[0],G=rV(Q),F;F=G[0];let M=Q0(Z.entities,F);return new oZ(M,dW(Z.optimistic_entities,X),dW(Z.optimistic_mutations,W),Z.queries,Z.pending_fetches,Z.get_headers,Z.mutation_counter,Z.endpoint)}else return new oZ(Z.entities,dW(Z.optimistic_entities,X),dW(Z.optimistic_mutations,W),Z.queries,Z.pending_fetches,Z.get_headers,Z.mutation_counter,Z.endpoint)}else return Z}var $0="http://www.w3.org/2000/svg";function oJ(Z){return K0($0,"ellipse",Z,m)}function s8(Z){return K0($0,"rect",Z,m)}function lV(Z,W){return K0($0,"defs",Z,W)}function r8(Z,W){return K0($0,"g",Z,W)}function tJ(Z,W){return K0($0,"svg",Z,W)}function KK(Z,W){return K0($0,"linearGradient",Z,W)}function n8(Z){return K0($0,"stop",Z,m)}function oV(Z){return tJ(Y([w("viewBox","0 0 60 60"),w("xmlns","http://www.w3.org/2000/svg"),N(Z)]),Y([lV(Y([]),Y([KK(Y([x8("board1"),w("x1","0%"),w("y1","0%"),w("x2","100%"),w("y2","100%")]),Y([n8(Y([w("offset","0%"),w("stop-color","#FF6347"),w("stop-opacity","1")])),n8(Y([w("offset","100%"),w("stop-color","#FF4500"),w("stop-opacity","1")]))])),KK(Y([x8("board2"),w("x1","0%"),w("y1","0%"),w("x2","100%"),w("y2","100%")]),Y([n8(Y([w("offset","0%"),w("stop-color","#00CED1"),w("stop-opacity","1")])),n8(Y([w("offset","100%"),w("stop-color","#4682B4"),w("stop-opacity","1")]))]))])),r8(Y([w("transform","translate(30, 30)")]),Y([oJ(Y([w("cx","0"),w("cy","-8"),w("rx","15"),w("ry","6"),w("fill","url(#board1)")])),oJ(Y([w("cx","0"),w("cy","0"),w("rx","18"),w("ry","6"),w("fill","url(#board2)")])),oJ(Y([w("cx","0"),w("cy","8"),w("rx","12"),w("ry","6"),w("fill","#32CD32")]))]))]))}function tV(Z){return f(Y([N("border-b border-zinc-800 pb-4 mb-8")]),Y([f(Y([N("flex items-end justify-between")]),Y([y0(Y([L0("/"),N("flex items-center gap-3 hover:opacity-80 transition-opacity")]),Y([oV("w-10 h-10"),f(Y([]),Y([f0(Y([N("text-xs font-medium uppercase tracking-wider text-zinc-500")]),Y([x("quickslice")]))]))])),f(Y([N("flex gap-4 text-xs items-center")]),(()=>{if(Z instanceof L){let W=Z[0][0],J=Z[0][1],K=Y([y0(Y([L0("/"),N("px-3 py-1 text-zinc-400 hover:text-zinc-300 transition-colors")]),Y([x("Home")]))]),X;if(J)X=SZ(K,Y([y0(Y([L0("/settings"),N("px-3 py-1 text-zinc-400 hover:text-zinc-300 transition-colors")]),Y([x("Settings")]))]));else X=K;return SZ(X,Y([IZ(Y([N("px-3 py-1 text-zinc-400")]),Y([x(W)])),H9(Y([a6("POST"),l6("/logout")]),Y([dZ(Y([bW("submit"),N("px-3 py-1 text-zinc-400 hover:text-zinc-300 transition-colors cursor-pointer")]),Y([x("Logout")]))]))]))}else return Y([H9(Y([a6("POST"),l6("/oauth/authorize"),N("flex gap-2 items-center")]),Y([z8(Y([bW("text"),oX("login_hint"),f8("handle.bsky.social"),N("bg-zinc-900 border border-zinc-700 rounded px-2 py-1 text-xs text-zinc-300 placeholder-zinc-600 focus:border-zinc-500 focus:outline-none w-48"),tX(!0)])),dZ(Y([bW("submit"),N("bg-zinc-800 hover:bg-zinc-700 text-zinc-300 px-3 py-1 rounded transition-colors")]),Y([x("Login")]))]))])})())]))]))}function XK(Z,W){console.log("[readFileAsBase64] Called with fileInputId:",Z);let J=document.getElementById(Z);if(!J){console.log("[readFileAsBase64] File input not found"),W(new q("File input not found"));return}console.log("[readFileAsBase64] Input element:",J),console.log("[readFileAsBase64] Input files:",J.files);let K=J.files?.[0];if(!K){console.log("[readFileAsBase64] No file selected"),W(new q("No file selected"));return}console.log("[readFileAsBase64] Reading file:",K.name);let X=new FileReader;X.onload=(V)=>{try{let Q=V.target.result.split(",")[1];W(new H(Q))}catch(Q){W(new q(`Failed to encode file: ${Q.message}`))}},X.onerror=()=>{W(new q("Failed to read file"))},X.readAsDataURL(K)}function VK(Z){let W=document.getElementById(Z);if(W)W.value=""}function eV(){let Z=RV(),W=vW(Z,"TriggerBackfill",`mutation TriggerBackfill { 6 + triggerBackfill 7 + }`,"generated/queries/trigger_backfill"),J=vW(W,"GetCurrentSession",`query GetCurrentSession { 8 + currentSession { 9 + __typename 10 + did 11 + handle 12 + isAdmin 13 + } 14 + }`,"generated/queries/get_current_session"),K=vW(J,"GetActivityBuckets",`query GetActivityBuckets($range: TimeRange!) { 15 + activityBuckets(range: $range) { 16 + __typename 17 + timestamp 18 + total 19 + creates 20 + updates 21 + deletes 22 + } 23 + }`,"generated/queries/get_activity_buckets"),X=vW(K,"GetRecentActivity",`query GetRecentActivity($hours: Int!) { 24 + recentActivity(hours: $hours) { 25 + __typename 26 + id 27 + timestamp 28 + operation 29 + collection 30 + did 31 + status 32 + errorMessage 33 + eventJson 34 + } 35 + }`,"generated/queries/get_recent_activity"),V=vW(X,"GetStatistics",`query GetStatistics { 36 + statistics { 37 + __typename 38 + recordCount 39 + actorCount 40 + lexiconCount 41 + } 42 + }`,"generated/queries/get_statistics"),Q=vW(V,"GetSettings",`query GetSettings { 43 + settings { 44 + __typename 45 + id 46 + domainAuthority 47 + oauthClientId 48 + } 49 + }`,"generated/queries/get_settings"),G=vW(Q,"UpdateDomainAuthority",`mutation UpdateDomainAuthority($domainAuthority: String!) { 50 + updateDomainAuthority(domainAuthority: $domainAuthority) { 51 + __typename 52 + id 53 + domainAuthority 54 + oauthClientId 55 + } 56 + }`,"generated/queries/update_domain_authority"),F=vW(G,"UploadLexicons",`mutation UploadLexicons($zipBase64: String!) { 57 + uploadLexicons(zipBase64: $zipBase64) 58 + }`,"generated/queries/upload_lexicons");return vW(F,"ResetAll",`mutation ResetAll($confirm: String!) { 59 + resetAll(confirm: $confirm) 60 + }`,"generated/queries/reset_all")}class i8 extends I{}class l8 extends I{}class a8 extends I{}class m0 extends I{}class o8 extends I{}class ZQ extends I{constructor(Z,W,J,K,X){super();this.timestamp=Z,this.total=W,this.creates=J,this.updates=K,this.deletes=X}}class WQ extends I{constructor(Z){super();this.activity_buckets=Z}}function P8(Z){if(Z instanceof i8)return"ONE_HOUR";else if(Z instanceof l8)return"THREE_HOURS";else if(Z instanceof a8)return"SIX_HOURS";else if(Z instanceof m0)return"ONE_DAY";else return"SEVEN_DAYS"}function v5(){return $("timestamp",c,(Z)=>{return $("total",mZ,(W)=>{return $("creates",mZ,(J)=>{return $("updates",mZ,(K)=>{return $("deletes",mZ,(X)=>{return t(new ZQ(Z,W,J,K,X))})})})})})}function _5(){return $("activityBuckets",PW(v5()),(Z)=>{return t(new WQ(Z))})}function R8(Z){return wZ(Z,_5())}class JQ extends I{constructor(Z,W,J){super();this.did=Z,this.handle=W,this.is_admin=J}}class KQ extends I{constructor(Z){super();this.current_session=Z}}function $5(){return $("did",c,(Z)=>{return $("handle",c,(W)=>{return $("isAdmin",BW,(J)=>{return t(new JQ(Z,W,J))})})})}function m5(){return $("currentSession",sW($5()),(Z)=>{return t(new KQ(Z))})}function YK(Z){return wZ(Z,m5())}class XQ extends I{constructor(Z,W,J,K,X,V,Q,G){super();this.id=Z,this.timestamp=W,this.operation=J,this.collection=K,this.did=X,this.status=V,this.error_message=Q,this.event_json=G}}class VQ extends I{constructor(Z){super();this.recent_activity=Z}}function c5(){return $("id",mZ,(Z)=>{return $("timestamp",c,(W)=>{return $("operation",c,(J)=>{return $("collection",c,(K)=>{return $("did",c,(X)=>{return $("status",c,(V)=>{return $("errorMessage",sW(c),(Q)=>{return $("eventJson",sW(c),(G)=>{return t(new XQ(Z,W,J,K,X,V,Q,G))})})})})})})})})}function p5(){return $("recentActivity",PW(c5()),(Z)=>{return t(new VQ(Z))})}function t8(Z){return wZ(Z,p5())}class YQ extends I{constructor(Z,W,J){super();this.id=Z,this.domain_authority=W,this.oauth_client_id=J}}class GQ extends I{constructor(Z){super();this.settings=Z}}function d5(){return $("id",c,(Z)=>{return $("domainAuthority",c,(W)=>{return $("oauthClientId",sW(c),(J)=>{return t(new YQ(Z,W,J))})})})}function s5(){return $("settings",d5(),(Z)=>{return t(new GQ(Z))})}function XW(Z){return wZ(Z,s5())}class FQ extends I{constructor(Z,W,J){super();this.record_count=Z,this.actor_count=W,this.lexicon_count=J}}class MQ extends I{constructor(Z){super();this.statistics=Z}}function r5(){return $("recordCount",mZ,(Z)=>{return $("actorCount",mZ,(W)=>{return $("lexiconCount",mZ,(J)=>{return t(new FQ(Z,W,J))})})})}function n5(){return $("statistics",r5(),(Z)=>{return t(new MQ(Z))})}function u0(Z){return wZ(Z,n5())}class UQ extends I{constructor(Z){super();this.reset_all=Z}}function i5(){return $("resetAll",BW,(Z)=>{return t(new UQ(Z))})}function IQ(Z){return wZ(Z,i5())}class zQ extends I{constructor(Z){super();this.trigger_backfill=Z}}function a5(){return $("triggerBackfill",BW,(Z)=>{return t(new zQ(Z))})}function HQ(Z){return wZ(Z,a5())}class NQ extends I{constructor(Z,W,J){super();this.id=Z,this.domain_authority=W,this.oauth_client_id=J}}class DQ extends I{constructor(Z){super();this.update_domain_authority=Z}}function t5(){return $("id",c,(Z)=>{return $("domainAuthority",c,(W)=>{return $("oauthClientId",sW(c),(J)=>{return t(new NQ(Z,W,J))})})})}function e5(){return $("updateDomainAuthority",t5(),(Z)=>{return t(new DQ(Z))})}function BQ(Z){return wZ(Z,e5())}class PQ extends I{constructor(Z){super();this.upload_lexicons=Z}}function W4(){return $("uploadLexicons",BW,(Z)=>{return t(new PQ(Z))})}function RQ(Z){return wZ(Z,W4())}function MK(Z){globalThis.location.href=Z}function jQ(Z,W){return nX(Z,C8(W,(J)=>{return new E8(!1,!1,J)}),m,n6,n6,0,0)}function HW(Z){return jQ("click",t(Z))}function eJ(Z){return jQ("input",x6(Y(["target","value"]),c,(W)=>{return t(Z(W))}))}function X4(Z,W){let J=(K)=>{let X="px-3 py-1 text-xs rounded transition-colors cursor-pointer";if(GZ(K,Z))return X+" bg-zinc-700 text-zinc-100";else return X+" bg-zinc-800/50 text-zinc-400 hover:bg-zinc-700/50 hover:text-zinc-300"};return f(Y([N("flex gap-2 mb-4")]),Y([dZ(Y([N(J(new i8)),HW(W(new i8))]),Y([x("1hr")])),dZ(Y([N(J(new l8)),HW(W(new l8))]),Y([x("3hr")])),dZ(Y([N(J(new a8)),HW(W(new a8))]),Y([x("6hr")])),dZ(Y([N(J(new m0)),HW(W(new m0))]),Y([x("1 day")])),dZ(Y([N(J(new o8)),HW(W(new o8))]),Y([x("7 day")]))]))}function V4(Z){let J=HZ(Z,(X)=>{return X.creates+X.updates+X.deletes}),K=iK(J,zJ);return W8(K,1)}function Q4(Z,W,J,K,X,V){let Q=n(W)*(J+K);if(Z.creates+Z.updates+Z.deletes===0){let F=4,M=X-F;return r8(Y([]),Y([s8(Y([w("x",BZ(Q)),w("y",BZ(M)),w("width",BZ(J)),w("height",BZ(F)),w("style","fill: #3f3f46 !important; stroke: none; display: inline")]))]))}else{let F=JJ(X,n(V)),M=n(Z.deletes)*F,U=n(Z.updates)*F,z=n(Z.creates)*F,D=X-M,O=D-U,R=O-z;return r8(Y([N("group")]),Y([(()=>{if(Z.deletes>0)return s8(Y([w("x",BZ(Q)),w("y",BZ(D)),w("width",BZ(J)),w("height",BZ(M)),w("style","fill: #ef4444 !important; stroke: none; display: inline; cursor: pointer; transition: opacity 0.2s"),N("group-hover:opacity-80")]));else return cZ()})(),(()=>{if(Z.updates>0)return s8(Y([w("x",BZ(Q)),w("y",BZ(O)),w("width",BZ(J)),w("height",BZ(U)),w("style","fill: #60a5fa !important; stroke: none; display: inline; cursor: pointer; transition: opacity 0.2s"),N("group-hover:opacity-80")]));else return cZ()})(),(()=>{if(Z.creates>0)return s8(Y([w("x",BZ(Q)),w("y",BZ(R)),w("width",BZ(J)),w("height",BZ(z)),w("style","fill: #22c55e !important; stroke: none; display: inline; cursor: pointer; transition: opacity 0.2s"),N("group-hover:opacity-80")]));else return cZ()})()]))}}function Y4(Z,W){let J=V4(Z),K;if(W instanceof o8)K=[160,12];else K=[30,4];let X=K,V,Q;V=X[0],Q=X[1];let G=S8(Z),F=n(G)*V+n(G-1)*Q,M=120;return f(Y([N("w-full")]),Y([tJ(Y([w("viewBox","0 0 "+BZ(F)+" "+BZ(M)),w("width","100%"),w("height",BZ(M)),w("style","min-height: 120px"),w("preserveAspectRatio","none")]),dK(Z,(U,z)=>{return Q4(U,z,V,Q,M,J)}))]))}function G4(Z,W){let J=h(Y([["range",a(P8(W))]])),K=e(Z,"GetActivityBuckets",J,R8),X;if(X=K[1],X instanceof AW)return f(Y([N("py-8 text-center text-zinc-600 text-xs")]),Y([x("Loading activity data...")]));else if(X instanceof SW){let V=X[0];return f(Y([N("py-8 text-center text-red-400 text-xs")]),Y([x("Error: "+V)]))}else{let Q=X[0].activity_buckets;if(Q instanceof T)return f(Y([N("py-8 text-center text-zinc-600 text-xs")]),Y([x("No activity data available")]));else return Y4(Q,W)}}function OQ(Z,W,J){return f(Y([N("bg-zinc-800/50 rounded p-4 font-mono mb-8")]),Y([X4(W,J),G4(Z,W)]))}function IK(Z){try{return new Date(Z).toLocaleTimeString("en-US",{hour:"2-digit",minute:"2-digit",second:"2-digit",hour12:!1})}catch(W){return Z}}function zK(Z){try{return new Date(Z).toLocaleString("en-US",{year:"numeric",month:"2-digit",day:"2-digit",hour:"2-digit",minute:"2-digit",second:"2-digit",hour12:!1})}catch(W){return Z}}function Z6(Z){if(typeof Z==="string")try{let W=JSON.parse(Z);return Z6(W)}catch(W){return Z}else if(Array.isArray(Z))return Z.map(Z6);else if(Z!==null&&typeof Z==="object"){let W={};for(let[J,K]of Object.entries(Z))W[J]=Z6(K);return W}return Z}function HK(Z){try{let W=JSON.parse(Z),J=Z6(W);return JSON.stringify(J,null,2)}catch(W){return Z}}function I4(Z){return f(Y([]),HZ(Z,(W)=>{let J,K=W.status;if(K==="success")J="text-green-500";else if(K==="validation_error")J="text-yellow-500";else if(K==="error")J="text-red-500";else if(K==="processing")J="text-blue-500";else J="text-zinc-500";let X=J,V,Q=W.status;if(Q==="success")V="✓";else if(Q==="validation_error")V="⚠";else if(Q==="error")V="✗";else if(Q==="processing")V="⋯";else V="•";let G=V,F,M=W.operation;if(M==="create")F="text-green-400";else if(M==="update")F="text-blue-400";else if(M==="delete")F="text-red-400";else F="text-zinc-400";let U=F,z="activity-"+qZ(W.id);return f(Y([N("border-l-2 border-zinc-700/50 hover:border-zinc-600 transition-colors"),w("data-entry-id",z)]),Y([f(Y([N("flex items-start gap-2 py-1 text-xs font-mono hover:bg-zinc-900/30 cursor-pointer group"),w("onclick","this.parentElement.classList.toggle('expanded')")]),Y([IZ(Y([N("text-zinc-600 group-hover:text-zinc-400 shrink-0 select-none transition-transform caret"),w("data-caret","")]),Y([x("›")])),IZ(Y([N("text-zinc-600 shrink-0 w-16"),w("data-timestamp",W.timestamp)]),Y([x(IK(W.timestamp))])),IZ(Y([N(X+" shrink-0 w-4")]),Y([x(G)])),IZ(Y([N(U+" shrink-0 w-12")]),Y([x(W.operation)])),IZ(Y([N("text-purple-400 shrink-0")]),Y([x(W.collection)])),IZ(Y([N("text-zinc-500 truncate")]),Y([x(W.did)]))])),f(Y([N("px-6 py-2 text-xs bg-zinc-900/50 border-t border-zinc-800 hidden space-y-1"),w("data-details","")]),Y([f(Y([N("flex gap-2")]),Y([IZ(Y([N("text-zinc-600 w-20")]),Y([x("Timestamp:")])),IZ(Y([N("text-zinc-400")]),Y([x(zK(W.timestamp))]))])),f(Y([N("flex gap-2")]),Y([IZ(Y([N("text-zinc-600 w-20")]),Y([x("DID:")])),IZ(Y([N("text-zinc-400 font-mono break-all")]),Y([x(W.did)]))])),f(Y([N("flex gap-2")]),Y([IZ(Y([N("text-zinc-600 w-20")]),Y([x("Status:")])),IZ(Y([N((()=>{let D=W.status;if(D==="success")return"text-green-400";else if(D==="validation_error")return"text-yellow-400";else if(D==="error")return"text-red-400";else return"text-zinc-400"})())]),Y([x(W.status)]))])),(()=>{let D=W.error_message;if(D instanceof L){let O=D[0];return f(Y([N("flex gap-2")]),Y([IZ(Y([N("text-zinc-600 w-20")]),Y([x("Error:")])),IZ(Y([N("text-red-400")]),Y([x(O)]))]))}else return cZ()})(),(()=>{let D=W.event_json;if(D instanceof L){let O=D[0],R=HK(O);return f(Y([N("mt-2")]),Y([f(Y([N("text-zinc-600 mb-1")]),Y([x("Event JSON:")])),D7(Y([N("text-zinc-400 bg-black/40 p-2 rounded text-[10px] whitespace-pre-wrap block"),w("data-json",O)]),Y([x(R)]))]))}else return cZ()})()]))]))}))}function TQ(Z,W){let J=h(Y([["hours",WW(W)]])),K=e(Z,"GetRecentActivity",J,t8),X;return X=K[1],f(Y([N("font-mono mb-8")]),Y([bZ("style",Y([]),Y([x(`[data-entry-id].expanded [data-caret] { transform: rotate(90deg); } 61 + [data-entry-id].expanded [data-details] { display: block !important; }`)])),f(Y([N("bg-zinc-800/50 rounded p-4")]),Y([f(Y([N("flex items-center justify-between mb-3")]),Y([f(Y([N("text-sm text-zinc-500")]),Y([x("JetStream Activity")])),(()=>{if(X instanceof CW){let V=X[0];return IZ(Y([N("text-xs text-zinc-600")]),Y([x(qZ(S8(V.recent_activity))+" events ("+qZ(W)+"h)")]))}else return IZ(Y([N("text-xs text-zinc-600")]),Y([x("("+qZ(W)+"h)")]))})()])),f(Y([N("max-h-80 overflow-y-auto")]),Y([(()=>{if(X instanceof AW)return f(Y([N("py-8 text-center text-zinc-600 text-xs")]),Y([x("Loading activity...")]));else if(X instanceof SW){let V=X[0];return f(Y([N("py-8 text-center text-red-400 text-xs")]),Y([x("Error: "+V)]))}else{let Q=X[0].recent_activity;if(Q instanceof T)return f(Y([N("py-8 text-center text-zinc-600 text-xs")]),Y([x("No activity in the last "+qZ(W)+" hours")]));else return I4(Q)}})()]))]))]))}class e8 extends I{}class ZJ extends I{}class c0 extends I{}class NK extends I{}function AQ(Z,W){let J;if(Z instanceof e8)J=["bg-green-900/30","border-green-800","text-green-300","✓"];else if(Z instanceof ZJ)J=["bg-red-900/30","border-red-800","text-red-300","✗"];else if(Z instanceof c0)J=["bg-blue-900/30","border-blue-800","text-blue-300","ℹ"];else J=["bg-yellow-900/30","border-yellow-800","text-yellow-300","⚠"];let K=J,X,V,Q,G;return X=K[0],V=K[1],Q=K[2],G=K[3],f(Y([N("mb-6 p-4 rounded border "+X+" "+V)]),Y([f(Y([N("flex items-center gap-3")]),Y([IZ(Y([N("text-lg "+Q)]),Y([x(G)])),IZ(Y([N("text-sm "+Q)]),Y([x(W)]))]))]))}function DK(Z,W,J,K){let X;if(Z instanceof e8)X=["bg-green-900/30","border-green-800","text-green-300","✓"];else if(Z instanceof ZJ)X=["bg-red-900/30","border-red-800","text-red-300","✗"];else if(Z instanceof c0)X=["bg-blue-900/30","border-blue-800","text-blue-300","ℹ"];else X=["bg-yellow-900/30","border-yellow-800","text-yellow-300","⚠"];let V=X,Q,G,F,M;return Q=V[0],G=V[1],F=V[2],M=V[3],f(Y([N("mb-6 p-4 rounded border "+Q+" "+G)]),Y([f(Y([N("flex items-center gap-3")]),Y([IZ(Y([N("text-lg "+F)]),Y([x(M)])),IZ(Y([N("text-sm "+F)]),Y([x(W+" "),y0(Y([L0(K),N("underline hover:no-underline")]),Y([x(J)]))]))]))]))}var H4="font-mono px-4 py-2 text-sm text-zinc-300 bg-zinc-800 hover:bg-zinc-700 rounded transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-zinc-800";function W6(Z,W,J){return dZ(Y([bW("button"),N(H4),k8(Z),HW(W)]),Y([gW(J)]))}function WJ(Z){return new Intl.NumberFormat("en-US").format(Z)}function BK(Z,W){return f(Y([N("bg-zinc-800/50 rounded p-4")]),Y([f(Y([N("text-sm text-zinc-500 mb-1")]),Y([gW(Z)])),f(Y([N("text-2xl font-semibold text-zinc-200")]),Y([gW(W)]))]))}function PK(Z){return f(Y([N("bg-zinc-800/50 rounded p-4 animate-pulse")]),Y([f(Y([N("text-sm text-zinc-500 mb-1")]),Y([gW(Z)])),f(Y([N("h-8 bg-zinc-700 rounded w-24")]),Y([]))]))}function CQ(Z){let W=e(Z,"GetStatistics",h(Y([])),u0),J;if(J=W[1],J instanceof AW)return f(Y([N("mb-8 grid grid-cols-3 gap-4")]),Y([PK("Total Records"),PK("Total Actors"),PK("Total Lexicons")]));else if(J instanceof SW){let K=J[0];return f(Y([N("mb-8")]),Y([f(Y([N("bg-red-800/50 rounded p-4 text-red-200")]),Y([gW("Error loading statistics: "+K)]))]))}else{let X=J[0].statistics;return f(Y([N("mb-8 grid grid-cols-3 gap-4")]),Y([BK("Total Records",WJ(X.record_count)),BK("Total Actors",WJ(X.actor_count)),BK("Total Lexicons",WJ(X.lexicon_count))]))}}class J6 extends I{constructor(Z){super();this[0]=Z}}class K6 extends I{}class RK extends I{}function P4(Z,W){let J;if(Z==="")J=DK(new NK,"No domain authority configured.","Settings","/settings");else J=cZ();let K=J,X;if(W===0)X=DK(new c0,"No lexicons loaded.","Settings","/settings");else X=cZ();let V=X;return f(Y([]),Y([K,V]))}function qQ(Z,W,J,K){let X=e(Z,"GetStatistics",h(Y([])),u0),V;V=X[1];let Q=e(Z,"GetSettings",h(Y([])),XW),G;G=Q[1];let F;if(V instanceof CW&&G instanceof CW){let U=V[0],z=G[0];F=P4(z.settings.domain_authority,U.statistics.lexicon_count)}else F=cZ();let M=F;return f(Y([]),Y([M,f(Y([N("mb-8 flex gap-3")]),(()=>{if(K)return Y([W6(!1,new RK,"Open GraphiQL"),W6(J,new K6,(()=>{if(J)return"Backfilling...";else return"Trigger Backfill"})())]);else return Y([W6(!1,new RK,"Open GraphiQL")])})()),CQ(Z),OQ(Z,W,(U)=>{return new J6(U)}),TQ(Z,24)]))}class X6 extends I{constructor(Z){super();this[0]=Z}}class V6 extends I{}class Q6 extends I{}class Y6 extends I{}class G6 extends I{constructor(Z){super();this[0]=Z}}class wQ extends I{}class VW extends I{constructor(Z,W,J,K){super();this.domain_authority_input=Z,this.reset_confirmation=W,this.selected_file=J,this.alert=K}}function mW(Z,W,J){return new VW(Z.domain_authority_input,Z.reset_confirmation,Z.selected_file,new L([W,J]))}function LQ(Z){return new VW(Z.domain_authority_input,Z.reset_confirmation,Z.selected_file,new g)}function EQ(){return new VW("","",new g,new g)}function j4(Z){return dV(Z)}function O4(Z,W,J){return f(Y([N("bg-zinc-800/50 rounded p-6")]),Y([v8(Y([N("text-xl font-semibold text-zinc-300 mb-4")]),Y([x("Domain Authority")])),f(Y([N("space-y-4")]),Y([f(Y([N("mb-4")]),Y([kJ(Y([N("block text-sm text-zinc-400 mb-2")]),Y([x("Domain Authority")])),z8(Y([bW("text"),N("font-mono px-4 py-2 text-sm text-zinc-300 bg-zinc-900 border border-zinc-800 rounded focus:outline-none focus:border-zinc-700 w-full"),f8("e.g. com.example"),o6(W.domain_authority_input),eJ((K)=>{return new X6(K)})]))])),pZ(Y([N("text-sm text-zinc-500 mb-4")]),Y([x('The domain authority is used to determine which collections are considered "primary" vs "external" when backfilling records. For example, if the authority is "xyz.statusphere", then "xyz.statusphere.status" is treated as primary and "app.bsky.actor.profile" is external.')])),f(Y([N("flex gap-3")]),Y([dZ(Y([N((()=>{if(J)return"font-mono px-4 py-2 text-sm text-zinc-500 bg-zinc-800 rounded cursor-not-allowed";else return"font-mono px-4 py-2 text-sm text-zinc-300 bg-zinc-800 hover:bg-zinc-700 rounded transition-colors cursor-pointer"})()),k8(J),HW(new V6)]),Y([x((()=>{if(J)return"Saving...";else return"Save"})())]))]))]))]))}function T4(Z){return f(Y([N("bg-zinc-800/50 rounded p-6")]),Y([v8(Y([N("text-xl font-semibold text-zinc-300 mb-4")]),Y([x("OAuth Configuration")])),(()=>{let W=Z.oauth_client_id;if(W instanceof L){let J=W[0];return f(Y([N("space-y-3")]),Y([f(Y([N("flex items-center gap-2")]),Y([f(Y([N("w-2 h-2 bg-green-500 rounded-full")]),Y([])),pZ(Y([N("text-sm text-zinc-300")]),Y([x("OAuth client registered")]))])),f(Y([N("bg-zinc-900/50 rounded p-3")]),Y([pZ(Y([N("text-xs text-zinc-500 mb-1")]),Y([x("Client ID:")])),pZ(Y([N("text-sm text-zinc-300 font-mono")]),Y([x(J)]))])),pZ(Y([N("text-sm text-zinc-500")]),Y([x('OAuth client credentials are stored in the database. Use "Reset Everything" to clear and trigger re-registration.')]))]))}else return f(Y([N("space-y-3")]),Y([f(Y([N("flex items-center gap-2")]),Y([f(Y([N("w-2 h-2 bg-zinc-500 rounded-full")]),Y([])),pZ(Y([N("text-sm text-zinc-400")]),Y([x("OAuth client not registered")]))])),pZ(Y([N("text-sm text-zinc-500")]),Y([x("Set ENABLE_OAUTH_AUTO_REGISTER=true in your .env file to enable automatic OAuth client registration. The server will automatically register with your configured AIP server on startup.")]))]))})()]))}function A4(Z){return f(Y([N("bg-zinc-800/50 rounded p-6")]),Y([v8(Y([N("text-xl font-semibold text-zinc-300 mb-4")]),Y([x("Lexicons")])),f(Y([N("space-y-4")]),Y([f(Y([N("mb-4")]),Y([kJ(Y([N("block text-sm text-zinc-400 mb-2")]),Y([x("Upload Lexicons (ZIP)")])),z8(Y([bW("file"),aX(Y([".zip"])),N("font-mono px-4 py-2 text-sm text-zinc-300 bg-zinc-900 border border-zinc-800 rounded focus:outline-none focus:border-zinc-700 w-full"),x8("lexicon-file-input"),eJ((W)=>{return new Q6})]))])),pZ(Y([N("text-sm text-zinc-500 mb-4")]),Y([x("Upload a ZIP file containing lexicon JSON files. The ZIP file will be extracted and all .json files will be imported into the database. This replaces the need to manually place lexicons in the priv/lexicons directory.")])),f(Y([N("flex gap-3")]),Y([dZ(Y([N("font-mono px-4 py-2 text-sm text-zinc-300 bg-zinc-800 hover:bg-zinc-700 rounded transition-colors cursor-pointer"),HW(new Y6)]),Y([x("Upload")]))]))]))]))}function S4(Z){return f(Y([N("bg-zinc-800/50 rounded p-6")]),Y([v8(Y([N("text-xl font-semibold text-zinc-300 mb-4")]),Y([x("Danger Zone")])),pZ(Y([N("text-sm text-zinc-400 mb-4")]),Y([x("This will clear all indexed data:")])),B7(Y([N("text-sm text-zinc-400 mb-4 ml-4 list-disc")]),Y([I8(Y([]),Y([x("Domain authority configuration")])),I8(Y([]),Y([x("OAuth client credentials")])),I8(Y([]),Y([x("All lexicon definitions")])),I8(Y([]),Y([x("All indexed records")])),I8(Y([]),Y([x("All actors")]))])),pZ(Y([N("text-sm text-zinc-400 mb-4")]),Y([x("Records can be re-indexed via backfill.")])),f(Y([N("space-y-4")]),Y([f(Y([N("mb-4")]),Y([kJ(Y([N("block text-sm text-zinc-400 mb-2")]),Y([x("Type RESET to confirm")])),z8(Y([bW("text"),N("font-mono px-4 py-2 text-sm text-zinc-300 bg-zinc-900 border border-zinc-800 rounded focus:outline-none focus:border-zinc-700 w-full"),f8("RESET"),o6(Z.reset_confirmation),eJ((W)=>{return new G6(W)})]))])),f(Y([N("flex gap-3")]),Y([dZ(Y([N("font-mono px-4 py-2 text-sm text-red-400 border border-red-900 hover:bg-red-900/30 rounded transition-colors cursor-pointer"),k8(Z.reset_confirmation!=="RESET"),HW(new wQ)]),Y([x("Reset Everything")]))]))]))]))}function xQ(Z,W,J){if(J){let K=h(Y([])),X=e(Z,"GetSettings",K,XW),V;V=X[1];let Q=j4(Z);return f(Y([N("max-w-2xl space-y-6")]),Y([f0(Y([N("text-2xl font-semibold text-zinc-300 mb-8")]),Y([x("Settings")])),(()=>{let G=W.alert;if(G instanceof L){let F=G[0][0],M=G[0][1],U;if(F==="success")U=new e8;else if(F==="error")U=new ZJ;else U=new c0;return AQ(U,M)}else return cZ()})(),(()=>{if(V instanceof AW)return f(Y([N("py-8 text-center text-zinc-600 text-sm")]),Y([x("Loading settings...")]));else if(V instanceof SW){let G=V[0];return f(Y([N("py-8 text-center text-red-400 text-sm")]),Y([x("Error: "+G)]))}else{let G=V[0];return f(Y([N("space-y-6")]),Y([O4(G.settings,W,Q),A4(W),T4(G.settings),S4(W)]))}})()]))}else return f(Y([N("max-w-2xl space-y-6")]),Y([f0(Y([N("text-2xl font-semibold text-zinc-300 mb-8")]),Y([x("Settings")])),f(Y([N("bg-zinc-800/50 rounded p-8 text-center border border-zinc-700")]),Y([pZ(Y([N("text-zinc-400 mb-4")]),Y([x("Access Denied")])),pZ(Y([N("text-sm text-zinc-500")]),Y([x("You must be an administrator to access the settings page.")]))]))]))}var q4="src/quickslice_client.gleam";class p0 extends I{}class j0 extends I{}class kQ extends I{}class Y0 extends I{}class fQ extends I{constructor(Z,W,J){super();this.did=Z,this.handle=W,this.is_admin=J}}class hZ extends I{constructor(Z,W,J,K,X,V,Q){super();this.cache=Z,this.registry=W,this.route=J,this.time_range=K,this.settings_page_model=X,this.is_backfilling=V,this.auth_state=Q}}class NW extends I{constructor(Z,W,J){super();this[0]=Z,this[1]=W,this[2]=J}}class jK extends I{constructor(Z,W){super();this[0]=Z,this[1]=W}}class OK extends I{constructor(Z,W){super();this[0]=Z,this[1]=W}}class TK extends I{constructor(Z){super();this[0]=Z}}class AK extends I{constructor(Z){super();this[0]=Z}}class SK extends I{constructor(Z){super();this[0]=Z}}class yQ extends I{constructor(Z){super();this[0]=Z}}function w4(Z,W){if(W instanceof NW){let J=W[2];if(J instanceof H){let K=W[0],X=W[1],V=J[0],Q=nV(Z.cache,K,X,V,0),G=qW(Q,Z.registry,(S,y,v)=>{return new NW(S,y,v)},()=>{return 0}),F,M;F=G[0],M=G[1];let U;if(K==="TriggerBackfill")U=!1;else U=Z.is_backfilling;let z=U,D;if(K==="GetCurrentSession"){let S=YK(V);if(S instanceof H){let v=S[0].current_session;if(v instanceof L){let _=v[0];D=new fQ(_.did,_.handle,_.is_admin)}else D=new Y0}else D=new Y0}else D=Z.auth_state;let O=D,R;if(K==="UpdateDomainAuthority")R=mW(Z.settings_page_model,"success","Domain authority updated successfully");else if(K==="UploadLexicons")VK("lexicon-file-input"),R=mW(Z.settings_page_model,"success","Lexicons uploaded successfully");else if(K==="ResetAll"){let S,y=Z.settings_page_model;S=new VW("",y.reset_confirmation,y.selected_file,y.alert),R=mW(S,"success","All data has been reset")}else if(K==="GetSettings"){let S=XW(V);if(S instanceof H){let y=S[0],v=Z.settings_page_model;R=new VW(y.settings.domain_authority,v.reset_confirmation,v.selected_file,v.alert)}else R=Z.settings_page_model}else R=Z.settings_page_model;let C=R,j;if(K==="ResetAll"){let S=R0(F,"GetStatistics",h(Y([]))),y=R0(S,"GetActivityBuckets",h(Y([["range",a(P8(Z.time_range))]]))),v=R0(y,"GetRecentActivity",h(Y([["hours",WW(24)]]))),_=e(v,"GetSettings",h(Y([])),XW),u;u=_[0];let YZ=qW(u,Z.registry,(FZ,zZ,ZZ)=>{return new NW(FZ,zZ,ZZ)},()=>{return 0}),sZ,_Z;sZ=YZ[0],_Z=YZ[1],j=[sZ,_Z]}else if(K==="UploadLexicons")j=[R0(F,"GetStatistics",h(Y([]))),Y([])];else j=[F,Y([])];let E=j,A,k;A=E[0],k=E[1];let b;if(K==="GetCurrentSession"){let S=Z.route;if(O instanceof Y0)if(S instanceof j0)b=Y([mJ("/",new g,new g)]);else b=Y([]);else if(!O.is_admin&&S instanceof j0)b=Y([mJ("/",new g,new g)]);else b=Y([])}else b=Y([]);let B=b;return[new hZ(A,Z.registry,Z.route,Z.time_range,C,z,O),jW((()=>{let S=Y([M,k,B]);return sK(S)})())]}else{let K=W[0],X=J[0],V;if(K==="TriggerBackfill")V=!1;else V=Z.is_backfilling;let Q=V,G;if(K==="UpdateDomainAuthority")G=mW(Z.settings_page_model,"error","Error: "+X);else if(K==="UploadLexicons")G=mW(Z.settings_page_model,"error","Error: "+X);else if(K==="ResetAll")G=mW(Z.settings_page_model,"error","Error: "+X);else if(K==="TriggerBackfill")G=mW(Z.settings_page_model,"error","Error: "+X);else G=Z.settings_page_model;let F=G;return[new hZ(Z.cache,Z.registry,Z.route,Z.time_range,F,Q,Z.auth_state),iZ()]}}else if(W instanceof jK){let J=W[0],K=W[1],X=iV(Z.cache,J,K),V=mW(Z.settings_page_model,"success","Domain authority updated successfully");return[new hZ(X,Z.registry,Z.route,Z.time_range,V,Z.is_backfilling,Z.auth_state),iZ()]}else if(W instanceof OK){let J=W[0],K=W[1],X=pV(Z.cache,J),V,G=e(X,"GetSettings",h(Y([])),XW)[1];if(G instanceof CW)V=G[0].settings.domain_authority;else V=Z.settings_page_model.domain_authority_input;let F=V,M,U=Z.settings_page_model;M=new VW(F,U.reset_confirmation,U.selected_file,new L(["error","Error: "+K]));let z=M;return[new hZ(X,Z.registry,Z.route,Z.time_range,z,Z.is_backfilling,Z.auth_state),iZ()]}else if(W instanceof TK){let J=W[0],K;if(Z.route instanceof j0)K=LQ(Z.settings_page_model);else K=Z.settings_page_model;let V=K;if(J instanceof p0){let Q=e(Z.cache,"GetStatistics",h(Y([])),u0),G;G=Q[0];let F=e(G,"GetSettings",h(Y([])),XW),M;M=F[0];let U=e(M,"GetActivityBuckets",h(Y([["range",a(P8(Z.time_range))]])),R8),z;z=U[0];let D=e(z,"GetRecentActivity",h(Y([["hours",WW(24)]])),t8),O;O=D[0];let R=qW(O,Z.registry,(E,A,k)=>{return new NW(E,A,k)},()=>{return 0}),C,j;return C=R[0],j=R[1],[new hZ(C,Z.registry,J,Z.time_range,V,Z.is_backfilling,Z.auth_state),jW(j)]}else if(J instanceof j0){let Q,G=Z.auth_state;if(G instanceof Y0)Q=!1;else Q=G.is_admin;if(Q){let M=e(Z.cache,"GetSettings",h(Y([])),XW),U;U=M[0];let z=qW(U,Z.registry,(R,C,j)=>{return new NW(R,C,j)},()=>{return 0}),D,O;return D=z[0],O=z[1],[new hZ(D,Z.registry,J,Z.time_range,V,Z.is_backfilling,Z.auth_state),jW(O)]}else return[Z,mJ("/",new g,new g)]}else return[new hZ(Z.cache,Z.registry,J,Z.time_range,V,Z.is_backfilling,Z.auth_state),iZ()]}else if(W instanceof AK){let J=W[0];if(J instanceof J6){let K=J[0],X=h(Y([["range",a(P8(K))]])),V=e(Z.cache,"GetActivityBuckets",X,R8),Q;Q=V[0];let G=qW(Q,Z.registry,(U,z,D)=>{return new NW(U,z,D)},()=>{return 0}),F,M;return F=G[0],M=G[1],[new hZ(F,Z.registry,Z.route,K,Z.settings_page_model,Z.is_backfilling,Z.auth_state),jW(M)]}else if(J instanceof K6){let K=h(Y([])),X=R0(Z.cache,"TriggerBackfill",K),V=e(X,"TriggerBackfill",K,HQ),Q;Q=V[0];let G=qW(Q,Z.registry,(U,z,D)=>{return new NW(U,z,D)},()=>{return 0}),F,M;return F=G[0],M=G[1],[new hZ(F,Z.registry,Z.route,Z.time_range,Z.settings_page_model,!0,Z.auth_state),jW(M)]}else return MK("/graphiql"),[Z,iZ()]}else if(W instanceof SK){let J=W[0];if(J instanceof X6){let K=J[0],X,V=Z.settings_page_model;X=new VW(K,V.reset_confirmation,V.selected_file,new g);let Q=X;return[new hZ(Z.cache,Z.registry,Z.route,Z.time_range,Q,Z.is_backfilling,Z.auth_state),iZ()]}else if(J instanceof V6){let K,X=Z.settings_page_model;K=new VW(X.domain_authority_input,X.reset_confirmation,X.selected_file,new g);let V=K,Q=h(Y([["domainAuthority",a(Z.settings_page_model.domain_authority_input)]])),G,M=e(Z.cache,"GetSettings",h(Y([])),XW)[1];if(M instanceof CW)G=M[0].settings.oauth_client_id;else G=new g;let U=G,z=h(Y([["id",a("Settings:singleton")],["domainAuthority",a(Z.settings_page_model.domain_authority_input)],["oauthClientId",K8(U,a)]])),D=sV(Z.cache,Z.registry,"UpdateDomainAuthority",Q,"Settings:singleton",(C)=>{return z},BQ,(C,j,E)=>{if(j instanceof H)return new jK(C,E);else{let A=j[0];return new OK(C,A)}}),O,R;return O=D[0],R=D[2],[new hZ(O,Z.registry,Z.route,Z.time_range,V,Z.is_backfilling,Z.auth_state),R]}else if(J instanceof Q6)return[Z,iZ()];else if(J instanceof Y6){q0("[UploadLexicons] Button clicked, creating file effect");let K=aW((X)=>{return q0("[UploadLexicons] Effect running, calling read_file_as_base64"),XK("lexicon-file-input",(V)=>{return q0("[UploadLexicons] Callback received result"),X(new yQ(V))})});return[Z,K]}else if(J instanceof G6){let K=J[0],X,V=Z.settings_page_model;X=new VW(V.domain_authority_input,K,V.selected_file,new g);let Q=X;return[new hZ(Z.cache,Z.registry,Z.route,Z.time_range,Q,Z.is_backfilling,Z.auth_state),iZ()]}else{let K=h(Y([["confirm",a(Z.settings_page_model.reset_confirmation)]])),X=R0(Z.cache,"ResetAll",K),V=e(X,"ResetAll",K,IQ),Q;Q=V[0];let G=qW(Q,Z.registry,(O,R,C)=>{return new NW(O,R,C)},()=>{return 0}),F,M;F=G[0],M=G[1];let U,z=Z.settings_page_model;U=new VW(z.domain_authority_input,"",z.selected_file,new g);let D=U;return[new hZ(F,Z.registry,Z.route,Z.time_range,D,Z.is_backfilling,Z.auth_state),jW(M)]}}else{let J=W[0];if(J instanceof H){let K=J[0];q0("[FileRead] Successfully read file, uploading...");let X=h(Y([["zipBase64",a(K)]])),V=R0(Z.cache,"UploadLexicons",X),Q=e(V,"UploadLexicons",X,RQ),G;G=Q[0];let F=qW(G,Z.registry,(R,C,j)=>{return new NW(R,C,j)},()=>{return 0}),M,U;M=F[0],U=F[1];let z,D=Z.settings_page_model;z=new VW(D.domain_authority_input,D.reset_confirmation,new g,D.alert);let O=z;return[new hZ(M,Z.registry,Z.route,Z.time_range,O,Z.is_backfilling,Z.auth_state),jW(U)]}else{let K=J[0];q0("[FileRead] Error reading file: "+K);let X=mW(Z.settings_page_model,"error",K);return[new hZ(Z.cache,Z.registry,Z.route,Z.time_range,X,Z.is_backfilling,Z.auth_state),iZ()]}}}function L4(Z){let W,J=Z.auth_state;if(J instanceof Y0)W=!1;else W=J.is_admin;let K=W;return z9(qQ(Z.cache,Z.time_range,Z.is_backfilling,K),(X)=>{return new AK(X)})}function E4(Z){let W,J=Z.auth_state;if(J instanceof Y0)W=!1;else W=J.is_admin;let K=W;return z9(xQ(Z.cache,Z.settings_page_model,K),(X)=>{return new SK(X)})}function x4(Z){return f(Y([]),Y([f0(Y([N("text-xl font-bold text-zinc-100 mb-4")]),Y([gW("Upload")])),pZ(Y([N("text-zinc-400")]),Y([gW("Upload and manage data")]))]))}function k4(Z){let W,J=Z.auth_state;if(J instanceof Y0)W=new g;else{let{handle:X,is_admin:V}=J;W=new L([X,V])}let K=W;return f(Y([N("bg-zinc-950 text-zinc-300 font-mono min-h-screen")]),Y([f(Y([N("max-w-4xl mx-auto px-6 py-12")]),Y([tV(K),(()=>{let X=Z.route;if(X instanceof p0)return L4(Z);else if(X instanceof j0)return E4(Z);else return x4(Z)})()]))]))}function bQ(Z){let W=Z.path;if(W==="/")return new p0;else if(W==="/settings")return new j0;else if(W==="/upload")return new kQ;else return new p0}function f4(Z){return new TK(bQ(Z))}function y4(Z){let W=cV("http://localhost:8000/admin/graphql"),J=eV(),K,X=m9();if(X instanceof H){let R=X[0];K=bQ(R)}else K=new p0;let V=K,Q=e(W,"GetCurrentSession",h(Y([])),YK),G;G=Q[0];let F;if(V instanceof p0){let R=e(G,"GetStatistics",h(Y([])),u0),C;C=R[0];let j=e(C,"GetSettings",h(Y([])),XW),E;E=j[0];let A=e(E,"GetActivityBuckets",h(Y([["range",a("ONE_DAY")]])),R8),k;k=A[0];let b=e(k,"GetRecentActivity",h(Y([["hours",WW(24)]])),t8),B;B=b[0];let S=qW(B,J,(_,u,YZ)=>{return new NW(_,u,YZ)},()=>{return 0}),y,v;y=S[0],v=S[1],F=[y,v]}else if(V instanceof j0){let R=e(G,"GetSettings",h(Y([])),XW),C;C=R[0];let j=qW(C,J,(k,b,B)=>{return new NW(k,b,B)},()=>{return 0}),E,A;E=j[0],A=j[1],F=[E,A]}else F=[G,Y([])];let M=F,U,z;U=M[0],z=M[1];let D=BV(f4),O=jW(P(D,z));return[new hZ(U,J,V,new m0,EQ(),!1,new Y0),O]}function gQ(){let Z=GV(y4,w4,k4),W=FV(Z,"#app",void 0);if(!(W instanceof H))throw F0("let_assert",q4,"quickslice_client",46,"main","Pattern match failed, no pattern matched the value.",{value:W,start:1103,end:1152,pattern_start:1114,pattern_end:1119});return W}gQ();
+1 -1
server/priv/static/styles.css
··· 1 1 /*! tailwindcss v4.1.17 | MIT License | https://tailwindcss.com */ 2 - @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-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking: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;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@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-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-800:oklch(44.4% .177 26.899);--color-red-900:oklch(39.6% .141 25.723);--color-red-950:oklch(25.8% .092 26.042);--color-yellow-300:oklch(90.5% .182 98.111);--color-yellow-400:oklch(85.2% .199 91.936);--color-yellow-500:oklch(79.5% .184 86.047);--color-yellow-800:oklch(47.6% .114 61.907);--color-yellow-900:oklch(42.1% .095 57.708);--color-green-300:oklch(87.1% .15 154.449);--color-green-400:oklch(79.2% .209 151.711);--color-green-500:oklch(72.3% .219 149.579);--color-green-800:oklch(44.8% .119 151.328);--color-green-900:oklch(39.3% .095 152.535);--color-blue-300:oklch(80.9% .105 251.813);--color-blue-400:oklch(70.7% .165 254.624);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-800:oklch(42.4% .199 265.638);--color-blue-900:oklch(37.9% .146 265.522);--color-purple-400:oklch(71.4% .203 305.504);--color-zinc-100:oklch(96.7% .001 286.375);--color-zinc-200:oklch(92% .004 286.32);--color-zinc-300:oklch(87.1% .006 286.286);--color-zinc-400:oklch(70.5% .015 286.067);--color-zinc-500:oklch(55.2% .016 285.938);--color-zinc-600:oklch(44.2% .017 285.786);--color-zinc-700:oklch(37% .013 285.805);--color-zinc-800:oklch(27.4% .006 286.033);--color-zinc-900:oklch(21% .006 285.885);--color-zinc-950:oklch(14.1% .005 285.823);--color-black:#000;--spacing:.25rem;--container-2xl:42rem;--container-4xl:56rem;--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-4xl:2.25rem;--text-4xl--line-height:calc(2.5/2.25);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-wider:.05em;--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{.absolute{position:absolute}.static{position:static}.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-6{margin-top:calc(var(--spacing)*6)}.mb-1{margin-bottom:calc(var(--spacing)*1)}.mb-2{margin-bottom:calc(var(--spacing)*2)}.mb-3{margin-bottom:calc(var(--spacing)*3)}.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}.inline{display:inline}.table{display:table}.h-2{height:calc(var(--spacing)*2)}.h-10{height:calc(var(--spacing)*10)}.h-full{height:100%}.max-h-80{max-height:calc(var(--spacing)*80)}.max-h-96{max-height:calc(var(--spacing)*96)}.min-h-screen{min-height:100vh}.w-2{width:calc(var(--spacing)*2)}.w-4{width:calc(var(--spacing)*4)}.w-10{width:calc(var(--spacing)*10)}.w-12{width:calc(var(--spacing)*12)}.w-16{width:calc(var(--spacing)*16)}.w-20{width:calc(var(--spacing)*20)}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-4xl{max-width:var(--container-4xl)}.shrink-0{flex-shrink:0}.transform{transform:var(--tw-rotate-x,)var(--tw-rotate-y,)var(--tw-rotate-z,)var(--tw-skew-x,)var(--tw-skew-y,)}.cursor-pointer{cursor:pointer}.list-disc{list-style-type:disc}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.gap-2{gap:calc(var(--spacing)*2)}.gap-3{gap:calc(var(--spacing)*3)}.gap-4{gap:calc(var(--spacing)*4)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*1)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*1)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*3)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*3)*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)))}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.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-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-blue-800{border-color:var(--color-blue-800)}.border-green-800{border-color:var(--color-green-800)}.border-red-800{border-color:var(--color-red-800)}.border-red-900{border-color:var(--color-red-900)}.border-yellow-800{border-color:var(--color-yellow-800)}.border-zinc-700{border-color:var(--color-zinc-700)}.border-zinc-700\/50{border-color:#3f3f4680}@supports (color:color-mix(in lab, red, red)){.border-zinc-700\/50{border-color:color-mix(in oklab,var(--color-zinc-700)50%,transparent)}}.border-zinc-800{border-color:var(--color-zinc-800)}.bg-black\/40{background-color:#0006}@supports (color:color-mix(in lab, red, red)){.bg-black\/40{background-color:color-mix(in oklab,var(--color-black)40%,transparent)}}.bg-blue-900\/30{background-color:#1c398e4d}@supports (color:color-mix(in lab, red, red)){.bg-blue-900\/30{background-color:color-mix(in oklab,var(--color-blue-900)30%,transparent)}}.bg-green-500{background-color:var(--color-green-500)}.bg-green-900\/30{background-color:#0d542b4d}@supports (color:color-mix(in lab, red, red)){.bg-green-900\/30{background-color:color-mix(in oklab,var(--color-green-900)30%,transparent)}}.bg-red-900\/30{background-color:#82181a4d}@supports (color:color-mix(in lab, red, red)){.bg-red-900\/30{background-color:color-mix(in oklab,var(--color-red-900)30%,transparent)}}.bg-red-950{background-color:var(--color-red-950)}.bg-yellow-900\/30{background-color:#733e0a4d}@supports (color:color-mix(in lab, red, red)){.bg-yellow-900\/30{background-color:color-mix(in oklab,var(--color-yellow-900)30%,transparent)}}.bg-zinc-500{background-color:var(--color-zinc-500)}.bg-zinc-700{background-color:var(--color-zinc-700)}.bg-zinc-800{background-color:var(--color-zinc-800)}.bg-zinc-800\/50{background-color:#27272a80}@supports (color:color-mix(in lab, red, red)){.bg-zinc-800\/50{background-color:color-mix(in oklab,var(--color-zinc-800)50%,transparent)}}.bg-zinc-900{background-color:var(--color-zinc-900)}.bg-zinc-900\/50{background-color:#18181b80}@supports (color:color-mix(in lab, red, red)){.bg-zinc-900\/50{background-color:color-mix(in oklab,var(--color-zinc-900)50%,transparent)}}.bg-zinc-950{background-color:var(--color-zinc-950)}.p-2{padding:calc(var(--spacing)*2)}.p-3{padding:calc(var(--spacing)*3)}.p-4{padding:calc(var(--spacing)*4)}.p-6{padding:calc(var(--spacing)*6)}.px-2{padding-inline:calc(var(--spacing)*2)}.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-1{padding-block:calc(var(--spacing)*1)}.py-2{padding-block:calc(var(--spacing)*2)}.py-3{padding-block:calc(var(--spacing)*3)}.py-4{padding-block:calc(var(--spacing)*4)}.py-8{padding-block:calc(var(--spacing)*8)}.py-12{padding-block:calc(var(--spacing)*12)}.pb-4{padding-bottom:calc(var(--spacing)*4)}.text-center{text-align:center}.text-right{text-align:right}.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-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))}.text-\[10px\]{font-size:10px}.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)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.break-all{word-break:break-all}.whitespace-pre-wrap{white-space:pre-wrap}.text-blue-300{color:var(--color-blue-300)}.text-blue-400{color:var(--color-blue-400)}.text-blue-500{color:var(--color-blue-500)}.text-green-300{color:var(--color-green-300)}.text-green-400{color:var(--color-green-400)}.text-green-500{color:var(--color-green-500)}.text-purple-400{color:var(--color-purple-400)}.text-red-300{color:var(--color-red-300)}.text-red-400{color:var(--color-red-400)}.text-red-500{color:var(--color-red-500)}.text-yellow-300{color:var(--color-yellow-300)}.text-yellow-400{color:var(--color-yellow-400)}.text-yellow-500{color:var(--color-yellow-500)}.text-zinc-100{color:var(--color-zinc-100)}.text-zinc-200{color:var(--color-zinc-200)}.text-zinc-300{color:var(--color-zinc-300)}.text-zinc-400{color:var(--color-zinc-400)}.text-zinc-500{color:var(--color-zinc-500)}.text-zinc-600{color:var(--color-zinc-600)}.lowercase{text-transform:lowercase}.uppercase{text-transform:uppercase}.underline{text-decoration-line:underline}.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)}.filter{filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-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))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.select-none{-webkit-user-select:none;user-select:none}@media (hover:hover){.group-hover\:fill-zinc-700:is(:where(.group):hover *){fill:var(--color-zinc-700)}.group-hover\:text-zinc-400:is(:where(.group):hover *){color:var(--color-zinc-400)}.group-hover\:opacity-80:is(:where(.group):hover *){opacity:.8}.hover\:border-zinc-600:hover{border-color:var(--color-zinc-600)}.hover\:bg-red-900\/30:hover{background-color:#82181a4d}@supports (color:color-mix(in lab, red, red)){.hover\:bg-red-900\/30:hover{background-color:color-mix(in oklab,var(--color-red-900)30%,transparent)}}.hover\:bg-zinc-600:hover{background-color:var(--color-zinc-600)}.hover\:bg-zinc-700:hover{background-color:var(--color-zinc-700)}.hover\:bg-zinc-700\/50:hover{background-color:#3f3f4680}@supports (color:color-mix(in lab, red, red)){.hover\:bg-zinc-700\/50:hover{background-color:color-mix(in oklab,var(--color-zinc-700)50%,transparent)}}.hover\:bg-zinc-900\/30:hover{background-color:#18181b4d}@supports (color:color-mix(in lab, red, red)){.hover\:bg-zinc-900\/30:hover{background-color:color-mix(in oklab,var(--color-zinc-900)30%,transparent)}}.hover\:text-zinc-100:hover{color:var(--color-zinc-100)}.hover\:text-zinc-200:hover{color:var(--color-zinc-200)}.hover\:text-zinc-300:hover{color:var(--color-zinc-300)}.hover\:no-underline:hover{text-decoration-line:none}.hover\:opacity-80:hover{opacity:.8}}.focus\:border-zinc-600:focus{border-color:var(--color-zinc-600)}.focus\:border-zinc-700:focus{border-color:var(--color-zinc-700)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}@media (hover:hover){.disabled\:hover\:bg-zinc-800:disabled:hover{background-color:var(--color-zinc-800)}}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{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:"<percentage>";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:"<percentage>";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:"<length>";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}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false} 2 + @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-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking: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;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@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-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-800:oklch(44.4% .177 26.899);--color-red-900:oklch(39.6% .141 25.723);--color-red-950:oklch(25.8% .092 26.042);--color-yellow-300:oklch(90.5% .182 98.111);--color-yellow-400:oklch(85.2% .199 91.936);--color-yellow-500:oklch(79.5% .184 86.047);--color-yellow-800:oklch(47.6% .114 61.907);--color-yellow-900:oklch(42.1% .095 57.708);--color-green-300:oklch(87.1% .15 154.449);--color-green-400:oklch(79.2% .209 151.711);--color-green-500:oklch(72.3% .219 149.579);--color-green-800:oklch(44.8% .119 151.328);--color-green-900:oklch(39.3% .095 152.535);--color-blue-300:oklch(80.9% .105 251.813);--color-blue-400:oklch(70.7% .165 254.624);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-800:oklch(42.4% .199 265.638);--color-blue-900:oklch(37.9% .146 265.522);--color-purple-400:oklch(71.4% .203 305.504);--color-zinc-100:oklch(96.7% .001 286.375);--color-zinc-200:oklch(92% .004 286.32);--color-zinc-300:oklch(87.1% .006 286.286);--color-zinc-400:oklch(70.5% .015 286.067);--color-zinc-500:oklch(55.2% .016 285.938);--color-zinc-600:oklch(44.2% .017 285.786);--color-zinc-700:oklch(37% .013 285.805);--color-zinc-800:oklch(27.4% .006 286.033);--color-zinc-900:oklch(21% .006 285.885);--color-zinc-950:oklch(14.1% .005 285.823);--color-black:#000;--spacing:.25rem;--container-2xl:42rem;--container-4xl:56rem;--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-4xl:2.25rem;--text-4xl--line-height:calc(2.5/2.25);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-wider:.05em;--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{.absolute{position:absolute}.static{position:static}.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-6{margin-top:calc(var(--spacing)*6)}.mb-1{margin-bottom:calc(var(--spacing)*1)}.mb-2{margin-bottom:calc(var(--spacing)*2)}.mb-3{margin-bottom:calc(var(--spacing)*3)}.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}.inline{display:inline}.table{display:table}.h-2{height:calc(var(--spacing)*2)}.h-10{height:calc(var(--spacing)*10)}.h-full{height:100%}.max-h-80{max-height:calc(var(--spacing)*80)}.max-h-96{max-height:calc(var(--spacing)*96)}.min-h-screen{min-height:100vh}.w-2{width:calc(var(--spacing)*2)}.w-4{width:calc(var(--spacing)*4)}.w-10{width:calc(var(--spacing)*10)}.w-12{width:calc(var(--spacing)*12)}.w-20{width:calc(var(--spacing)*20)}.w-40{width:calc(var(--spacing)*40)}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-4xl{max-width:var(--container-4xl)}.shrink-0{flex-shrink:0}.transform{transform:var(--tw-rotate-x,)var(--tw-rotate-y,)var(--tw-rotate-z,)var(--tw-skew-x,)var(--tw-skew-y,)}.cursor-pointer{cursor:pointer}.list-disc{list-style-type:disc}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.gap-2{gap:calc(var(--spacing)*2)}.gap-3{gap:calc(var(--spacing)*3)}.gap-4{gap:calc(var(--spacing)*4)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*1)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*1)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*3)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*3)*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)))}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.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-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-blue-800{border-color:var(--color-blue-800)}.border-green-800{border-color:var(--color-green-800)}.border-red-800{border-color:var(--color-red-800)}.border-red-900{border-color:var(--color-red-900)}.border-yellow-800{border-color:var(--color-yellow-800)}.border-zinc-700{border-color:var(--color-zinc-700)}.border-zinc-700\/50{border-color:#3f3f4680}@supports (color:color-mix(in lab, red, red)){.border-zinc-700\/50{border-color:color-mix(in oklab,var(--color-zinc-700)50%,transparent)}}.border-zinc-800{border-color:var(--color-zinc-800)}.bg-black\/40{background-color:#0006}@supports (color:color-mix(in lab, red, red)){.bg-black\/40{background-color:color-mix(in oklab,var(--color-black)40%,transparent)}}.bg-blue-900\/30{background-color:#1c398e4d}@supports (color:color-mix(in lab, red, red)){.bg-blue-900\/30{background-color:color-mix(in oklab,var(--color-blue-900)30%,transparent)}}.bg-green-500{background-color:var(--color-green-500)}.bg-green-900\/30{background-color:#0d542b4d}@supports (color:color-mix(in lab, red, red)){.bg-green-900\/30{background-color:color-mix(in oklab,var(--color-green-900)30%,transparent)}}.bg-red-900\/30{background-color:#82181a4d}@supports (color:color-mix(in lab, red, red)){.bg-red-900\/30{background-color:color-mix(in oklab,var(--color-red-900)30%,transparent)}}.bg-red-950{background-color:var(--color-red-950)}.bg-yellow-900\/30{background-color:#733e0a4d}@supports (color:color-mix(in lab, red, red)){.bg-yellow-900\/30{background-color:color-mix(in oklab,var(--color-yellow-900)30%,transparent)}}.bg-zinc-500{background-color:var(--color-zinc-500)}.bg-zinc-700{background-color:var(--color-zinc-700)}.bg-zinc-800{background-color:var(--color-zinc-800)}.bg-zinc-800\/50{background-color:#27272a80}@supports (color:color-mix(in lab, red, red)){.bg-zinc-800\/50{background-color:color-mix(in oklab,var(--color-zinc-800)50%,transparent)}}.bg-zinc-900{background-color:var(--color-zinc-900)}.bg-zinc-900\/50{background-color:#18181b80}@supports (color:color-mix(in lab, red, red)){.bg-zinc-900\/50{background-color:color-mix(in oklab,var(--color-zinc-900)50%,transparent)}}.bg-zinc-950{background-color:var(--color-zinc-950)}.p-2{padding:calc(var(--spacing)*2)}.p-3{padding:calc(var(--spacing)*3)}.p-4{padding:calc(var(--spacing)*4)}.p-6{padding:calc(var(--spacing)*6)}.px-2{padding-inline:calc(var(--spacing)*2)}.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-1{padding-block:calc(var(--spacing)*1)}.py-2{padding-block:calc(var(--spacing)*2)}.py-3{padding-block:calc(var(--spacing)*3)}.py-4{padding-block:calc(var(--spacing)*4)}.py-8{padding-block:calc(var(--spacing)*8)}.py-12{padding-block:calc(var(--spacing)*12)}.pb-4{padding-bottom:calc(var(--spacing)*4)}.text-center{text-align:center}.text-right{text-align:right}.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-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))}.text-\[10px\]{font-size:10px}.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)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.break-all{word-break:break-all}.whitespace-pre-wrap{white-space:pre-wrap}.text-blue-300{color:var(--color-blue-300)}.text-blue-400{color:var(--color-blue-400)}.text-blue-500{color:var(--color-blue-500)}.text-green-300{color:var(--color-green-300)}.text-green-400{color:var(--color-green-400)}.text-green-500{color:var(--color-green-500)}.text-purple-400{color:var(--color-purple-400)}.text-red-300{color:var(--color-red-300)}.text-red-400{color:var(--color-red-400)}.text-red-500{color:var(--color-red-500)}.text-yellow-300{color:var(--color-yellow-300)}.text-yellow-400{color:var(--color-yellow-400)}.text-yellow-500{color:var(--color-yellow-500)}.text-zinc-100{color:var(--color-zinc-100)}.text-zinc-200{color:var(--color-zinc-200)}.text-zinc-300{color:var(--color-zinc-300)}.text-zinc-400{color:var(--color-zinc-400)}.text-zinc-500{color:var(--color-zinc-500)}.text-zinc-600{color:var(--color-zinc-600)}.lowercase{text-transform:lowercase}.uppercase{text-transform:uppercase}.underline{text-decoration-line:underline}.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)}.filter{filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-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))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.select-none{-webkit-user-select:none;user-select:none}@media (hover:hover){.group-hover\:fill-zinc-700:is(:where(.group):hover *){fill:var(--color-zinc-700)}.group-hover\:text-zinc-400:is(:where(.group):hover *){color:var(--color-zinc-400)}.group-hover\:opacity-80:is(:where(.group):hover *){opacity:.8}.hover\:border-zinc-600:hover{border-color:var(--color-zinc-600)}.hover\:bg-red-900\/30:hover{background-color:#82181a4d}@supports (color:color-mix(in lab, red, red)){.hover\:bg-red-900\/30:hover{background-color:color-mix(in oklab,var(--color-red-900)30%,transparent)}}.hover\:bg-zinc-600:hover{background-color:var(--color-zinc-600)}.hover\:bg-zinc-700:hover{background-color:var(--color-zinc-700)}.hover\:bg-zinc-700\/50:hover{background-color:#3f3f4680}@supports (color:color-mix(in lab, red, red)){.hover\:bg-zinc-700\/50:hover{background-color:color-mix(in oklab,var(--color-zinc-700)50%,transparent)}}.hover\:bg-zinc-900\/30:hover{background-color:#18181b4d}@supports (color:color-mix(in lab, red, red)){.hover\:bg-zinc-900\/30:hover{background-color:color-mix(in oklab,var(--color-zinc-900)30%,transparent)}}.hover\:text-zinc-100:hover{color:var(--color-zinc-100)}.hover\:text-zinc-200:hover{color:var(--color-zinc-200)}.hover\:text-zinc-300:hover{color:var(--color-zinc-300)}.hover\:no-underline:hover{text-decoration-line:none}.hover\:opacity-80:hover{opacity:.8}}.focus\:border-zinc-600:focus{border-color:var(--color-zinc-600)}.focus\:border-zinc-700:focus{border-color:var(--color-zinc-700)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}@media (hover:hover){.disabled\:hover\:bg-zinc-800:disabled:hover{background-color:var(--color-zinc-800)}}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{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:"<percentage>";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:"<percentage>";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:"<length>";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}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}
-3
server/src/app.css
··· 1 - @import "tailwindcss"; 2 - 3 - @source "src/**/*.gleam";
+243
server/src/client_graphql_handler.gleam
··· 1 + /// GraphQL HTTP handler for client statistics and activity API 2 + /// 3 + /// This handler serves the /admin/graphql endpoint which provides 4 + /// stats and activity data to the client SPA using a separate schema 5 + import client_schema 6 + import gleam/bit_array 7 + import gleam/dict 8 + import gleam/dynamic/decode 9 + import gleam/erlang/process 10 + import gleam/http 11 + import gleam/json 12 + import gleam/list 13 + import gleam/option 14 + import jetstream_consumer 15 + import sqlight 16 + import swell/executor 17 + import swell/schema as swell_schema 18 + import swell/value 19 + import wisp 20 + 21 + /// Handle GraphQL HTTP requests for client API 22 + pub fn handle_client_graphql_request( 23 + req: wisp.Request, 24 + db: sqlight.Connection, 25 + admin_dids: List(String), 26 + jetstream_subject: option.Option(process.Subject(jetstream_consumer.Message)), 27 + ) -> wisp.Response { 28 + case req.method { 29 + http.Post -> handle_post(req, db, admin_dids, jetstream_subject) 30 + http.Get -> handle_get(req, db, admin_dids, jetstream_subject) 31 + _ -> method_not_allowed_response() 32 + } 33 + } 34 + 35 + fn handle_post( 36 + req: wisp.Request, 37 + db: sqlight.Connection, 38 + admin_dids: List(String), 39 + jetstream_subject: option.Option(process.Subject(jetstream_consumer.Message)), 40 + ) -> wisp.Response { 41 + case wisp.read_body_bits(req) { 42 + Ok(body) -> { 43 + case bit_array.to_string(body) { 44 + Ok(body_string) -> { 45 + case extract_query_and_variables_from_json(body_string) { 46 + Ok(#(query, variables)) -> execute_query(req, db, admin_dids, jetstream_subject, query, variables) 47 + Error(err) -> bad_request_response("Invalid JSON: " <> err) 48 + } 49 + } 50 + Error(_) -> bad_request_response("Request body must be valid UTF-8") 51 + } 52 + } 53 + Error(_) -> bad_request_response("Failed to read request body") 54 + } 55 + } 56 + 57 + fn handle_get( 58 + req: wisp.Request, 59 + db: sqlight.Connection, 60 + admin_dids: List(String), 61 + jetstream_subject: option.Option(process.Subject(jetstream_consumer.Message)), 62 + ) -> wisp.Response { 63 + let query_params = wisp.get_query(req) 64 + case list.key_find(query_params, "query") { 65 + Ok(query) -> execute_query(req, db, admin_dids, jetstream_subject, query, option.None) 66 + Error(_) -> bad_request_response("Missing 'query' parameter") 67 + } 68 + } 69 + 70 + fn execute_query( 71 + req: wisp.Request, 72 + db: sqlight.Connection, 73 + admin_dids: List(String), 74 + jetstream_subject: option.Option(process.Subject(jetstream_consumer.Message)), 75 + query: String, 76 + variables: option.Option(value.Value), 77 + ) -> wisp.Response { 78 + // Build the schema 79 + let graphql_schema = client_schema.build_schema(db, req, admin_dids, jetstream_subject) 80 + 81 + // Create context with variables 82 + let ctx = case variables { 83 + option.Some(value.Object(fields)) -> { 84 + // Convert list of tuples to dict 85 + let vars_dict = dict.from_list(fields) 86 + swell_schema.context_with_variables(option.None, vars_dict) 87 + } 88 + _ -> swell_schema.context(option.None) 89 + } 90 + 91 + // Execute the query 92 + case executor.execute(query, graphql_schema, ctx) { 93 + Ok(result) -> { 94 + // Convert executor response to JSON 95 + let response_json = case result.errors { 96 + [] -> { 97 + // Success with no errors 98 + json.object([#("data", value_to_json(result.data))]) 99 + } 100 + errors -> { 101 + // Convert GraphQLError records to JSON 102 + let errors_json = 103 + json.array(errors, fn(err) { 104 + json.object([ 105 + #("message", json.string(err.message)), 106 + #("path", json.array(err.path, json.string)), 107 + ]) 108 + }) 109 + 110 + // Partial success or errors 111 + json.object([ 112 + #("data", value_to_json(result.data)), 113 + #("errors", errors_json), 114 + ]) 115 + } 116 + } 117 + 118 + let json_string = json.to_string(response_json) 119 + success_response(json_string) 120 + } 121 + Error(err) -> internal_error_response(err) 122 + } 123 + } 124 + 125 + /// Convert a GraphQL Value to JSON 126 + fn value_to_json(val: value.Value) -> json.Json { 127 + case val { 128 + value.Null -> json.null() 129 + value.Int(i) -> json.int(i) 130 + value.Float(f) -> json.float(f) 131 + value.String(s) -> json.string(s) 132 + value.Boolean(b) -> json.bool(b) 133 + value.Enum(e) -> json.string(e) 134 + value.List(items) -> json.array(items, value_to_json) 135 + value.Object(fields) -> 136 + json.object(list.map(fields, fn(field) { 137 + #(field.0, value_to_json(field.1)) 138 + })) 139 + } 140 + } 141 + 142 + fn extract_query_and_variables_from_json( 143 + json_str: String, 144 + ) -> Result(#(String, option.Option(value.Value)), String) { 145 + // First just get the query 146 + let query_decoder = { 147 + use query <- decode.field("query", decode.string) 148 + decode.success(query) 149 + } 150 + 151 + case json.parse(json_str, query_decoder) { 152 + Ok(query) -> { 153 + // Try to parse variables separately (they're optional) 154 + let variables_decoder = { 155 + use vars <- decode.field("variables", decode.dynamic) 156 + decode.success(option.Some(vars)) 157 + } 158 + 159 + let variables_value = case json.parse(json_str, variables_decoder) { 160 + Ok(option.Some(vars)) -> option.Some(dynamic_to_value(vars)) 161 + _ -> option.None 162 + } 163 + 164 + Ok(#(query, variables_value)) 165 + } 166 + Error(_) -> Error("Invalid JSON or missing 'query' field") 167 + } 168 + } 169 + 170 + /// Convert a Dynamic value to a GraphQL Value 171 + /// For strings, we treat them as Enum values since GraphQL enums are sent as strings in JSON 172 + fn dynamic_to_value(dyn: decode.Dynamic) -> value.Value { 173 + // Try to decode as different types 174 + case decode.run(dyn, decode.dict(decode.string, decode.dynamic)) { 175 + Ok(dict_value) -> { 176 + // It's an object 177 + let fields = 178 + dict_value 179 + |> dict.to_list 180 + |> list.map(fn(pair) { 181 + let #(key, val) = pair 182 + #(key, dynamic_to_value(val)) 183 + }) 184 + value.Object(fields) 185 + } 186 + Error(_) -> 187 + case decode.run(dyn, decode.list(decode.dynamic)) { 188 + Ok(list_value) -> { 189 + let items = list.map(list_value, dynamic_to_value) 190 + value.List(items) 191 + } 192 + Error(_) -> 193 + case decode.run(dyn, decode.int) { 194 + Ok(i) -> value.Int(i) 195 + Error(_) -> 196 + case decode.run(dyn, decode.float) { 197 + Ok(f) -> value.Float(f) 198 + Error(_) -> 199 + case decode.run(dyn, decode.bool) { 200 + Ok(b) -> value.Boolean(b) 201 + Error(_) -> 202 + case decode.run(dyn, decode.string) { 203 + Ok(str) -> value.String(str) 204 + Error(_) -> value.Null 205 + } 206 + } 207 + } 208 + } 209 + } 210 + } 211 + } 212 + 213 + // Response helpers 214 + 215 + fn success_response(data: String) -> wisp.Response { 216 + wisp.response(200) 217 + |> wisp.set_header("content-type", "application/json") 218 + |> wisp.set_body(wisp.Text(data)) 219 + } 220 + 221 + fn bad_request_response(message: String) -> wisp.Response { 222 + wisp.response(400) 223 + |> wisp.set_header("content-type", "application/json") 224 + |> wisp.set_body(wisp.Text( 225 + "{\"error\": \"BadRequest\", \"message\": \"" <> message <> "\"}", 226 + )) 227 + } 228 + 229 + fn internal_error_response(message: String) -> wisp.Response { 230 + wisp.response(500) 231 + |> wisp.set_header("content-type", "application/json") 232 + |> wisp.set_body(wisp.Text( 233 + "{\"error\": \"InternalError\", \"message\": \"" <> message <> "\"}", 234 + )) 235 + } 236 + 237 + fn method_not_allowed_response() -> wisp.Response { 238 + wisp.response(405) 239 + |> wisp.set_header("content-type", "application/json") 240 + |> wisp.set_body(wisp.Text( 241 + "{\"error\": \"MethodNotAllowed\", \"message\": \"Only POST and GET are allowed\"}", 242 + )) 243 + }
+834
server/src/client_schema.gleam
··· 1 + /// GraphQL schema for client statistics and activity queries 2 + /// 3 + /// This schema is separate from the main /graphql endpoint and serves 4 + /// the client SPA with stats and activity data via /admin/graphql 5 + import backfill 6 + import database 7 + import gleam/erlang/process 8 + import gleam/list 9 + import gleam/option.{type Option, None, Some} 10 + import importer 11 + import jetstream_activity 12 + import jetstream_consumer 13 + import logging 14 + import oauth/session 15 + import sqlight 16 + import swell/schema 17 + import swell/value 18 + import wisp 19 + 20 + // ===== Helper Functions ===== 21 + 22 + /// Check if a DID is in the admin list 23 + fn is_admin(did: String, admin_dids: List(String)) -> Bool { 24 + list.contains(admin_dids, did) 25 + } 26 + 27 + /// Convert CurrentSession data to GraphQL value 28 + fn current_session_to_value(did: String, handle: String, is_admin: Bool) -> value.Value { 29 + value.Object([ 30 + #("did", value.String(did)), 31 + #("handle", value.String(handle)), 32 + #("isAdmin", value.Boolean(is_admin)), 33 + ]) 34 + } 35 + 36 + // ===== Enum Types ===== 37 + 38 + /// TimeRange enum for activity queries 39 + pub fn time_range_enum() -> schema.Type { 40 + schema.enum_type("TimeRange", "Time range for activity data", [ 41 + schema.enum_value("ONE_HOUR", "Last 1 hour (5-min buckets)"), 42 + schema.enum_value("THREE_HOURS", "Last 3 hours (15-min buckets)"), 43 + schema.enum_value("SIX_HOURS", "Last 6 hours (30-min buckets)"), 44 + schema.enum_value("ONE_DAY", "Last 24 hours (1-hour buckets)"), 45 + schema.enum_value("SEVEN_DAYS", "Last 7 days (daily buckets)"), 46 + ]) 47 + } 48 + 49 + // ===== Object Types ===== 50 + 51 + /// Statistics type showing record, actor, and lexicon counts 52 + pub fn statistics_type() -> schema.Type { 53 + schema.object_type("Statistics", "System statistics", [ 54 + schema.field( 55 + "recordCount", 56 + schema.non_null(schema.int_type()), 57 + "Total number of records", 58 + fn(ctx) { 59 + case ctx.data { 60 + Some(value.Object(fields)) -> { 61 + case list.key_find(fields, "recordCount") { 62 + Ok(count) -> Ok(count) 63 + Error(_) -> Ok(value.Null) 64 + } 65 + } 66 + _ -> Ok(value.Null) 67 + } 68 + }, 69 + ), 70 + schema.field( 71 + "actorCount", 72 + schema.non_null(schema.int_type()), 73 + "Total number of actors", 74 + fn(ctx) { 75 + case ctx.data { 76 + Some(value.Object(fields)) -> { 77 + case list.key_find(fields, "actorCount") { 78 + Ok(count) -> Ok(count) 79 + Error(_) -> Ok(value.Null) 80 + } 81 + } 82 + _ -> Ok(value.Null) 83 + } 84 + }, 85 + ), 86 + schema.field( 87 + "lexiconCount", 88 + schema.non_null(schema.int_type()), 89 + "Total number of lexicons", 90 + fn(ctx) { 91 + case ctx.data { 92 + Some(value.Object(fields)) -> { 93 + case list.key_find(fields, "lexiconCount") { 94 + Ok(count) -> Ok(count) 95 + Error(_) -> Ok(value.Null) 96 + } 97 + } 98 + _ -> Ok(value.Null) 99 + } 100 + }, 101 + ), 102 + ]) 103 + } 104 + 105 + /// CurrentSession type for authenticated user information 106 + pub fn current_session_type() -> schema.Type { 107 + schema.object_type("CurrentSession", "Current authenticated user session", [ 108 + schema.field( 109 + "did", 110 + schema.non_null(schema.string_type()), 111 + "User's DID", 112 + fn(ctx) { 113 + case ctx.data { 114 + Some(value.Object(fields)) -> { 115 + case list.key_find(fields, "did") { 116 + Ok(did) -> Ok(did) 117 + Error(_) -> Ok(value.Null) 118 + } 119 + } 120 + _ -> Ok(value.Null) 121 + } 122 + }, 123 + ), 124 + schema.field( 125 + "handle", 126 + schema.non_null(schema.string_type()), 127 + "User's handle", 128 + fn(ctx) { 129 + case ctx.data { 130 + Some(value.Object(fields)) -> { 131 + case list.key_find(fields, "handle") { 132 + Ok(handle) -> Ok(handle) 133 + Error(_) -> Ok(value.Null) 134 + } 135 + } 136 + _ -> Ok(value.Null) 137 + } 138 + }, 139 + ), 140 + schema.field( 141 + "isAdmin", 142 + schema.non_null(schema.boolean_type()), 143 + "Whether the user is an admin", 144 + fn(ctx) { 145 + case ctx.data { 146 + Some(value.Object(fields)) -> { 147 + case list.key_find(fields, "isAdmin") { 148 + Ok(is_admin) -> Ok(is_admin) 149 + Error(_) -> Ok(value.Null) 150 + } 151 + } 152 + _ -> Ok(value.Null) 153 + } 154 + }, 155 + ), 156 + ]) 157 + } 158 + 159 + /// ActivityBucket type for aggregated activity data 160 + pub fn activity_bucket_type() -> schema.Type { 161 + schema.object_type("ActivityBucket", "Time-bucketed activity counts", [ 162 + schema.field( 163 + "timestamp", 164 + schema.non_null(schema.string_type()), 165 + "Bucket timestamp", 166 + fn(ctx) { 167 + case ctx.data { 168 + Some(value.Object(fields)) -> { 169 + case list.key_find(fields, "timestamp") { 170 + Ok(ts) -> Ok(ts) 171 + Error(_) -> Ok(value.Null) 172 + } 173 + } 174 + _ -> Ok(value.Null) 175 + } 176 + }, 177 + ), 178 + schema.field( 179 + "total", 180 + schema.non_null(schema.int_type()), 181 + "Total operations in bucket", 182 + fn(ctx) { 183 + case ctx.data { 184 + Some(value.Object(fields)) -> { 185 + case list.key_find(fields, "total") { 186 + Ok(total) -> Ok(total) 187 + Error(_) -> Ok(value.Null) 188 + } 189 + } 190 + _ -> Ok(value.Null) 191 + } 192 + }, 193 + ), 194 + schema.field( 195 + "creates", 196 + schema.non_null(schema.int_type()), 197 + "Create operations", 198 + fn(ctx) { 199 + case ctx.data { 200 + Some(value.Object(fields)) -> { 201 + case list.key_find(fields, "creates") { 202 + Ok(creates) -> Ok(creates) 203 + Error(_) -> Ok(value.Null) 204 + } 205 + } 206 + _ -> Ok(value.Null) 207 + } 208 + }, 209 + ), 210 + schema.field( 211 + "updates", 212 + schema.non_null(schema.int_type()), 213 + "Update operations", 214 + fn(ctx) { 215 + case ctx.data { 216 + Some(value.Object(fields)) -> { 217 + case list.key_find(fields, "updates") { 218 + Ok(updates) -> Ok(updates) 219 + Error(_) -> Ok(value.Null) 220 + } 221 + } 222 + _ -> Ok(value.Null) 223 + } 224 + }, 225 + ), 226 + schema.field( 227 + "deletes", 228 + schema.non_null(schema.int_type()), 229 + "Delete operations", 230 + fn(ctx) { 231 + case ctx.data { 232 + Some(value.Object(fields)) -> { 233 + case list.key_find(fields, "deletes") { 234 + Ok(deletes) -> Ok(deletes) 235 + Error(_) -> Ok(value.Null) 236 + } 237 + } 238 + _ -> Ok(value.Null) 239 + } 240 + }, 241 + ), 242 + ]) 243 + } 244 + 245 + /// Settings type for configuration 246 + pub fn settings_type() -> schema.Type { 247 + schema.object_type("Settings", "System settings and configuration", [ 248 + schema.field( 249 + "id", 250 + schema.non_null(schema.string_type()), 251 + "Global ID for normalization", 252 + fn(_ctx) { 253 + // Settings is a singleton, so we use a constant ID 254 + Ok(value.String("Settings:singleton")) 255 + }, 256 + ), 257 + schema.field( 258 + "domainAuthority", 259 + schema.non_null(schema.string_type()), 260 + "Domain authority configuration", 261 + fn(ctx) { 262 + case ctx.data { 263 + Some(value.Object(fields)) -> { 264 + case list.key_find(fields, "domainAuthority") { 265 + Ok(authority) -> Ok(authority) 266 + Error(_) -> Ok(value.Null) 267 + } 268 + } 269 + _ -> Ok(value.Null) 270 + } 271 + }, 272 + ), 273 + schema.field("oauthClientId", schema.string_type(), "OAuth client ID if registered", fn( 274 + ctx, 275 + ) { 276 + case ctx.data { 277 + Some(value.Object(fields)) -> { 278 + case list.key_find(fields, "oauthClientId") { 279 + Ok(client_id) -> Ok(client_id) 280 + Error(_) -> Ok(value.Null) 281 + } 282 + } 283 + _ -> Ok(value.Null) 284 + } 285 + }), 286 + ]) 287 + } 288 + 289 + /// ActivityEntry type for individual activity records 290 + pub fn activity_entry_type() -> schema.Type { 291 + schema.object_type("ActivityEntry", "Individual activity log entry", [ 292 + schema.field( 293 + "id", 294 + schema.non_null(schema.int_type()), 295 + "Entry ID", 296 + fn(ctx) { 297 + case ctx.data { 298 + Some(value.Object(fields)) -> { 299 + case list.key_find(fields, "id") { 300 + Ok(id) -> Ok(id) 301 + Error(_) -> Ok(value.Null) 302 + } 303 + } 304 + _ -> Ok(value.Null) 305 + } 306 + }, 307 + ), 308 + schema.field( 309 + "timestamp", 310 + schema.non_null(schema.string_type()), 311 + "Timestamp", 312 + fn(ctx) { 313 + case ctx.data { 314 + Some(value.Object(fields)) -> { 315 + case list.key_find(fields, "timestamp") { 316 + Ok(ts) -> Ok(ts) 317 + Error(_) -> Ok(value.Null) 318 + } 319 + } 320 + _ -> Ok(value.Null) 321 + } 322 + }, 323 + ), 324 + schema.field( 325 + "operation", 326 + schema.non_null(schema.string_type()), 327 + "Operation type", 328 + fn(ctx) { 329 + case ctx.data { 330 + Some(value.Object(fields)) -> { 331 + case list.key_find(fields, "operation") { 332 + Ok(op) -> Ok(op) 333 + Error(_) -> Ok(value.Null) 334 + } 335 + } 336 + _ -> Ok(value.Null) 337 + } 338 + }, 339 + ), 340 + schema.field( 341 + "collection", 342 + schema.non_null(schema.string_type()), 343 + "Collection name", 344 + fn(ctx) { 345 + case ctx.data { 346 + Some(value.Object(fields)) -> { 347 + case list.key_find(fields, "collection") { 348 + Ok(coll) -> Ok(coll) 349 + Error(_) -> Ok(value.Null) 350 + } 351 + } 352 + _ -> Ok(value.Null) 353 + } 354 + }, 355 + ), 356 + schema.field( 357 + "did", 358 + schema.non_null(schema.string_type()), 359 + "DID", 360 + fn(ctx) { 361 + case ctx.data { 362 + Some(value.Object(fields)) -> { 363 + case list.key_find(fields, "did") { 364 + Ok(did) -> Ok(did) 365 + Error(_) -> Ok(value.Null) 366 + } 367 + } 368 + _ -> Ok(value.Null) 369 + } 370 + }, 371 + ), 372 + schema.field( 373 + "status", 374 + schema.non_null(schema.string_type()), 375 + "Processing status", 376 + fn(ctx) { 377 + case ctx.data { 378 + Some(value.Object(fields)) -> { 379 + case list.key_find(fields, "status") { 380 + Ok(status) -> Ok(status) 381 + Error(_) -> Ok(value.Null) 382 + } 383 + } 384 + _ -> Ok(value.Null) 385 + } 386 + }, 387 + ), 388 + schema.field("errorMessage", schema.string_type(), "Error message if failed", fn( 389 + ctx, 390 + ) { 391 + case ctx.data { 392 + Some(value.Object(fields)) -> { 393 + case list.key_find(fields, "errorMessage") { 394 + Ok(err_msg) -> Ok(err_msg) 395 + Error(_) -> Ok(value.Null) 396 + } 397 + } 398 + _ -> Ok(value.Null) 399 + } 400 + }), 401 + schema.field("eventJson", schema.string_type(), "Raw event JSON", fn(ctx) { 402 + case ctx.data { 403 + Some(value.Object(fields)) -> { 404 + case list.key_find(fields, "eventJson") { 405 + Ok(json) -> Ok(json) 406 + Error(_) -> Ok(value.Null) 407 + } 408 + } 409 + _ -> Ok(value.Null) 410 + } 411 + }), 412 + ]) 413 + } 414 + 415 + // ===== Conversion Helpers ===== 416 + 417 + fn statistics_to_value( 418 + record_count: Int, 419 + actor_count: Int, 420 + lexicon_count: Int, 421 + ) -> value.Value { 422 + value.Object([ 423 + #("recordCount", value.Int(record_count)), 424 + #("actorCount", value.Int(actor_count)), 425 + #("lexiconCount", value.Int(lexicon_count)), 426 + ]) 427 + } 428 + 429 + fn activity_bucket_to_value( 430 + bucket: jetstream_activity.ActivityBucket, 431 + ) -> value.Value { 432 + let total = bucket.create_count + bucket.update_count + bucket.delete_count 433 + value.Object([ 434 + #("timestamp", value.String(bucket.timestamp)), 435 + #("total", value.Int(total)), 436 + #("creates", value.Int(bucket.create_count)), 437 + #("updates", value.Int(bucket.update_count)), 438 + #("deletes", value.Int(bucket.delete_count)), 439 + ]) 440 + } 441 + 442 + fn activity_entry_to_value( 443 + entry: jetstream_activity.ActivityEntry, 444 + ) -> value.Value { 445 + let error_msg_value = case entry.error_message { 446 + Some(msg) -> value.String(msg) 447 + None -> value.Null 448 + } 449 + 450 + value.Object([ 451 + #("id", value.Int(entry.id)), 452 + #("timestamp", value.String(entry.timestamp)), 453 + #("operation", value.String(entry.operation)), 454 + #("collection", value.String(entry.collection)), 455 + #("did", value.String(entry.did)), 456 + #("status", value.String(entry.status)), 457 + #("errorMessage", error_msg_value), 458 + #("eventJson", value.String(entry.event_json)), 459 + ]) 460 + } 461 + 462 + fn settings_to_value( 463 + domain_authority: String, 464 + oauth_client_id: Option(String), 465 + ) -> value.Value { 466 + let oauth_value = case oauth_client_id { 467 + Some(id) -> value.String(id) 468 + None -> value.Null 469 + } 470 + 471 + value.Object([ 472 + #("id", value.String("Settings:singleton")), 473 + #("domainAuthority", value.String(domain_authority)), 474 + #("oauthClientId", oauth_value), 475 + ]) 476 + } 477 + 478 + // ===== Query Type ===== 479 + 480 + pub fn query_type( 481 + conn: sqlight.Connection, 482 + req: wisp.Request, 483 + admin_dids: List(String), 484 + ) -> schema.Type { 485 + schema.object_type("Query", "Root query type", [ 486 + // currentSession query 487 + schema.field( 488 + "currentSession", 489 + current_session_type(), 490 + "Get current authenticated user session (null if not authenticated)", 491 + fn(_ctx) { 492 + case session.get_current_session(req, conn) { 493 + Ok(sess) -> { 494 + let user_is_admin = is_admin(sess.did, admin_dids) 495 + Ok(current_session_to_value(sess.did, sess.handle, user_is_admin)) 496 + } 497 + Error(_) -> Ok(value.Null) 498 + } 499 + }, 500 + ), 501 + // statistics query 502 + schema.field( 503 + "statistics", 504 + schema.non_null(statistics_type()), 505 + "Get system statistics", 506 + fn(_ctx) { 507 + case 508 + database.get_record_count(conn), 509 + database.get_actor_count(conn), 510 + database.get_lexicon_count(conn) 511 + { 512 + Ok(record_count), Ok(actor_count), Ok(lexicon_count) -> { 513 + Ok(statistics_to_value(record_count, actor_count, lexicon_count)) 514 + } 515 + _, _, _ -> Error("Failed to fetch statistics") 516 + } 517 + }, 518 + ), 519 + // settings query 520 + schema.field( 521 + "settings", 522 + schema.non_null(settings_type()), 523 + "Get system settings", 524 + fn(_ctx) { 525 + let domain_authority = case database.get_config(conn, "domain_authority") { 526 + Ok(authority) -> authority 527 + Error(_) -> "" 528 + } 529 + 530 + let oauth_client_id = case database.get_oauth_credentials(conn) { 531 + Ok(Some(#(client_id, _secret, _uri))) -> Some(client_id) 532 + _ -> None 533 + } 534 + 535 + Ok(settings_to_value(domain_authority, oauth_client_id)) 536 + }, 537 + ), 538 + // activityBuckets query with TimeRange argument 539 + schema.field_with_args( 540 + "activityBuckets", 541 + schema.non_null(schema.list_type(schema.non_null(activity_bucket_type()))), 542 + "Get activity data bucketed by time range", 543 + [ 544 + schema.argument( 545 + "range", 546 + schema.non_null(time_range_enum()), 547 + "Time range for bucketing", 548 + None, 549 + ), 550 + ], 551 + fn(ctx) { 552 + case schema.get_argument(ctx, "range") { 553 + Some(value.String("ONE_HOUR")) -> { 554 + case jetstream_activity.get_activity_1hr(conn) { 555 + Ok(buckets) -> 556 + Ok(value.List(list.map(buckets, activity_bucket_to_value))) 557 + Error(_) -> Error("Failed to fetch 1-hour activity data") 558 + } 559 + } 560 + Some(value.String("THREE_HOURS")) -> { 561 + case jetstream_activity.get_activity_3hr(conn) { 562 + Ok(buckets) -> 563 + Ok(value.List(list.map(buckets, activity_bucket_to_value))) 564 + Error(_) -> Error("Failed to fetch 3-hour activity data") 565 + } 566 + } 567 + Some(value.String("SIX_HOURS")) -> { 568 + case jetstream_activity.get_activity_6hr(conn) { 569 + Ok(buckets) -> 570 + Ok(value.List(list.map(buckets, activity_bucket_to_value))) 571 + Error(_) -> Error("Failed to fetch 6-hour activity data") 572 + } 573 + } 574 + Some(value.String("ONE_DAY")) -> { 575 + case jetstream_activity.get_activity_1day(conn) { 576 + Ok(buckets) -> 577 + Ok(value.List(list.map(buckets, activity_bucket_to_value))) 578 + Error(_) -> Error("Failed to fetch 1-day activity data") 579 + } 580 + } 581 + Some(value.String("SEVEN_DAYS")) -> { 582 + case jetstream_activity.get_activity_7day(conn) { 583 + Ok(buckets) -> 584 + Ok(value.List(list.map(buckets, activity_bucket_to_value))) 585 + Error(_) -> Error("Failed to fetch 7-day activity data") 586 + } 587 + } 588 + _ -> Error("Invalid or missing time range argument") 589 + } 590 + }, 591 + ), 592 + // recentActivity query with hours argument 593 + schema.field_with_args( 594 + "recentActivity", 595 + schema.non_null(schema.list_type(schema.non_null(activity_entry_type()))), 596 + "Get recent activity entries", 597 + [ 598 + schema.argument( 599 + "hours", 600 + schema.non_null(schema.int_type()), 601 + "Number of hours to look back", 602 + None, 603 + ), 604 + ], 605 + fn(ctx) { 606 + case schema.get_argument(ctx, "hours") { 607 + Some(value.Int(hours)) -> { 608 + case jetstream_activity.get_recent_activity(conn, hours) { 609 + Ok(entries) -> 610 + Ok(value.List(list.map(entries, activity_entry_to_value))) 611 + Error(_) -> Error("Failed to fetch recent activity") 612 + } 613 + } 614 + _ -> Error("Invalid or missing hours argument") 615 + } 616 + }, 617 + ), 618 + ]) 619 + } 620 + 621 + /// Mutation type for settings updates 622 + pub fn mutation_type( 623 + conn: sqlight.Connection, 624 + req: wisp.Request, 625 + admin_dids: List(String), 626 + jetstream_subject: Option(process.Subject(jetstream_consumer.Message)), 627 + ) -> schema.Type { 628 + schema.object_type("Mutation", "Root mutation type", [ 629 + // updateDomainAuthority mutation 630 + schema.field_with_args( 631 + "updateDomainAuthority", 632 + schema.non_null(settings_type()), 633 + "Update domain authority configuration", 634 + [ 635 + schema.argument( 636 + "domainAuthority", 637 + schema.non_null(schema.string_type()), 638 + "New domain authority value", 639 + None, 640 + ), 641 + ], 642 + fn(ctx) { 643 + case schema.get_argument(ctx, "domainAuthority") { 644 + Some(value.String(authority)) -> { 645 + case database.set_config(conn, "domain_authority", authority) { 646 + Ok(_) -> { 647 + // Restart Jetstream consumer to pick up new domain authority 648 + case jetstream_subject { 649 + Some(consumer) -> { 650 + logging.log(logging.Info, "[updateDomainAuthority] Restarting Jetstream consumer with new domain authority...") 651 + let _ = jetstream_consumer.restart(consumer) 652 + Nil 653 + } 654 + None -> Nil 655 + } 656 + 657 + // Fetch OAuth client ID to return complete Settings 658 + let oauth_client_id = case database.get_oauth_credentials(conn) { 659 + Ok(Some(#(client_id, _secret, _uri))) -> Some(client_id) 660 + _ -> None 661 + } 662 + Ok(settings_to_value(authority, oauth_client_id)) 663 + } 664 + Error(_) -> Error("Failed to update domain authority") 665 + } 666 + } 667 + _ -> Error("Invalid domain authority argument") 668 + } 669 + }, 670 + ), 671 + // uploadLexicons mutation 672 + schema.field_with_args( 673 + "uploadLexicons", 674 + schema.non_null(schema.boolean_type()), 675 + "Upload and import lexicons from base64-encoded ZIP", 676 + [ 677 + schema.argument( 678 + "zipBase64", 679 + schema.non_null(schema.string_type()), 680 + "Base64-encoded ZIP file containing lexicon JSON files", 681 + None, 682 + ), 683 + ], 684 + fn(ctx) { 685 + case schema.get_argument(ctx, "zipBase64") { 686 + Some(value.String(zip_base64)) -> { 687 + // Import lexicons from base64-encoded ZIP 688 + case importer.import_lexicons_from_base64_zip(zip_base64, conn) { 689 + Ok(_stats) -> { 690 + // Restart Jetstream consumer to pick up newly imported collections 691 + case jetstream_subject { 692 + Some(consumer) -> { 693 + logging.log(logging.Info, "[uploadLexicons] Restarting Jetstream consumer with new lexicons...") 694 + case jetstream_consumer.restart(consumer) { 695 + Ok(_) -> { 696 + logging.log(logging.Info, "[uploadLexicons] Jetstream consumer restarted successfully") 697 + Ok(value.Boolean(True)) 698 + } 699 + Error(err) -> { 700 + logging.log(logging.Error, "[uploadLexicons] Failed to restart Jetstream consumer: " <> err) 701 + Error("Lexicons imported but failed to restart Jetstream consumer: " <> err) 702 + } 703 + } 704 + } 705 + None -> { 706 + logging.log(logging.Info, "[uploadLexicons] Jetstream consumer not running, skipping restart") 707 + Ok(value.Boolean(True)) 708 + } 709 + } 710 + } 711 + Error(err) -> Error("Failed to import lexicons: " <> err) 712 + } 713 + } 714 + _ -> Error("Invalid zipBase64 argument") 715 + } 716 + }, 717 + ), 718 + // resetAll mutation 719 + schema.field_with_args( 720 + "resetAll", 721 + schema.non_null(schema.boolean_type()), 722 + "Reset all data (requires RESET confirmation and admin privileges)", 723 + [ 724 + schema.argument( 725 + "confirm", 726 + schema.non_null(schema.string_type()), 727 + "Must be the string 'RESET' to confirm", 728 + None, 729 + ), 730 + ], 731 + fn(ctx) { 732 + // Check if user is authenticated and admin 733 + case session.get_current_session(req, conn) { 734 + Ok(sess) -> { 735 + case is_admin(sess.did, admin_dids) { 736 + True -> { 737 + case schema.get_argument(ctx, "confirm") { 738 + Some(value.String("RESET")) -> { 739 + // Call multiple database functions to reset all data 740 + let _ = database.delete_all_records(conn) 741 + let _ = database.delete_all_actors(conn) 742 + let _ = database.delete_all_lexicons(conn) 743 + let _ = database.delete_domain_authority(conn) 744 + let _ = database.delete_oauth_credentials(conn) 745 + let _ = database.delete_all_jetstream_activity(conn) 746 + 747 + // Restart Jetstream consumer after reset 748 + case jetstream_subject { 749 + Some(consumer) -> { 750 + logging.log(logging.Info, "[resetAll] Restarting Jetstream consumer after reset...") 751 + let _ = jetstream_consumer.restart(consumer) 752 + Nil 753 + } 754 + None -> Nil 755 + } 756 + 757 + Ok(value.Boolean(True)) 758 + } 759 + Some(value.String(_)) -> Error("Confirmation must be 'RESET'") 760 + _ -> Error("Invalid confirm argument") 761 + } 762 + } 763 + False -> Error("Admin privileges required to reset all data") 764 + } 765 + } 766 + Error(_) -> Error("Authentication required to reset all data") 767 + } 768 + }, 769 + ), 770 + // triggerBackfill mutation 771 + schema.field( 772 + "triggerBackfill", 773 + schema.non_null(schema.boolean_type()), 774 + "Trigger a background backfill operation for all collections (admin only)", 775 + fn(_ctx) { 776 + // Check if user is authenticated and admin 777 + case session.get_current_session(req, conn) { 778 + Ok(sess) -> { 779 + case is_admin(sess.did, admin_dids) { 780 + True -> { 781 + // Spawn background process to run backfill 782 + process.spawn_unlinked(fn() { 783 + logging.log(logging.Info, "[triggerBackfill] Starting background backfill...") 784 + 785 + // Get all record-type collections from database (only backfill records, not queries/procedures) 786 + let collections = case database.get_record_type_lexicons(conn) { 787 + Ok(lexicons) -> list.map(lexicons, fn(lex) { lex.id }) 788 + Error(_) -> [] 789 + } 790 + 791 + // Get domain authority to determine external collections 792 + let domain_authority = case database.get_config(conn, "domain_authority") { 793 + Ok(authority) -> authority 794 + Error(_) -> "" 795 + } 796 + 797 + // Split collections into primary and external 798 + let #(primary_collections, external_collections) = 799 + list.partition(collections, fn(collection) { 800 + backfill.nsid_matches_domain_authority(collection, domain_authority) 801 + }) 802 + 803 + // Run backfill with default config and empty repo list (fetches from relay) 804 + let config = backfill.default_config() 805 + backfill.backfill_collections([], primary_collections, external_collections, config, conn) 806 + 807 + logging.log(logging.Info, "[triggerBackfill] Background backfill completed") 808 + }) 809 + 810 + // Return immediately 811 + Ok(value.Boolean(True)) 812 + } 813 + False -> Error("Admin privileges required to trigger backfill") 814 + } 815 + } 816 + Error(_) -> Error("Authentication required to trigger backfill") 817 + } 818 + }, 819 + ), 820 + ]) 821 + } 822 + 823 + /// Build the complete GraphQL schema for client queries 824 + pub fn build_schema( 825 + conn: sqlight.Connection, 826 + req: wisp.Request, 827 + admin_dids: List(String), 828 + jetstream_subject: Option(process.Subject(jetstream_consumer.Message)), 829 + ) -> schema.Schema { 830 + schema.schema( 831 + query_type(conn, req, admin_dids), 832 + Some(mutation_type(conn, req, admin_dids, jetstream_subject)), 833 + ) 834 + }
-337
server/src/components/activity_chart.gleam
··· 1 - import gleam/erlang/process 2 - import gleam/float 3 - import gleam/int 4 - import gleam/list 5 - import gleam/result 6 - import jetstream_activity.{type ActivityBucket} 7 - import lustre 8 - import lustre/attribute 9 - import lustre/effect 10 - import lustre/element.{type Element} 11 - import lustre/element/html 12 - import lustre/element/svg 13 - import lustre/event 14 - import sqlight 15 - import stats_pubsub 16 - 17 - // APP 18 - 19 - pub fn component(db: sqlight.Connection) { 20 - lustre.application(init(db, _), update, view) 21 - } 22 - 23 - // MODEL 24 - 25 - pub type TimeRange { 26 - OneHour 27 - ThreeHour 28 - SixHour 29 - OneDay 30 - SevenDay 31 - } 32 - 33 - pub type Model { 34 - Model(db: sqlight.Connection, range: TimeRange, data: List(ActivityBucket)) 35 - } 36 - 37 - fn init(db: sqlight.Connection, _flags: Nil) -> #(Model, effect.Effect(Msg)) { 38 - // Default to 1 day 39 - let data = jetstream_activity.get_activity_1day(db) |> result.unwrap([]) 40 - #(Model(db: db, range: OneDay, data: data), start_listening_in_background()) 41 - } 42 - 43 - // UPDATE 44 - 45 - pub opaque type Msg { 46 - ChangeRange(TimeRange) 47 - StatsEventReceived(stats_pubsub.StatsEvent) 48 - } 49 - 50 - fn update(model: Model, msg: Msg) -> #(Model, effect.Effect(Msg)) { 51 - case msg { 52 - ChangeRange(range) -> { 53 - let data = case range { 54 - OneHour -> jetstream_activity.get_activity_1hr(model.db) 55 - ThreeHour -> jetstream_activity.get_activity_3hr(model.db) 56 - SixHour -> jetstream_activity.get_activity_6hr(model.db) 57 - OneDay -> jetstream_activity.get_activity_1day(model.db) 58 - SevenDay -> jetstream_activity.get_activity_7day(model.db) 59 - } 60 - |> result.unwrap([]) 61 - 62 - #(Model(..model, range: range, data: data), effect.none()) 63 - } 64 - 65 - StatsEventReceived(event) -> { 66 - case event { 67 - // When activity is logged or records are created/deleted, refresh the chart data 68 - stats_pubsub.ActivityLogged(_, _, _, _, _, _, _, _) 69 - | stats_pubsub.RecordCreated 70 - | stats_pubsub.RecordDeleted -> { 71 - // Refresh data for current time range 72 - let data = case model.range { 73 - OneHour -> jetstream_activity.get_activity_1hr(model.db) 74 - ThreeHour -> jetstream_activity.get_activity_3hr(model.db) 75 - SixHour -> jetstream_activity.get_activity_6hr(model.db) 76 - OneDay -> jetstream_activity.get_activity_1day(model.db) 77 - SevenDay -> jetstream_activity.get_activity_7day(model.db) 78 - } 79 - |> result.unwrap([]) 80 - 81 - #(Model(..model, data: data), effect.none()) 82 - } 83 - // Ignore other events 84 - _ -> #(model, effect.none()) 85 - } 86 - } 87 - } 88 - } 89 - 90 - // VIEW 91 - 92 - /// Render static chart for server-side pre-rendering 93 - pub fn render_static(data: List(ActivityBucket), range: TimeRange) -> Element(msg) { 94 - html.div([attribute.class("bg-zinc-800/50 rounded p-4 font-mono")], [ 95 - render_time_range_buttons_static(range), 96 - render_chart(data, range), 97 - ]) 98 - } 99 - 100 - fn view(model: Model) -> Element(Msg) { 101 - html.div([], [ 102 - // Include Tailwind styles in the Shadow DOM 103 - element.element( 104 - "link", 105 - [ 106 - attribute.attribute("rel", "stylesheet"), 107 - attribute.attribute("href", "/styles.css"), 108 - ], 109 - [], 110 - ), 111 - html.div([attribute.class("bg-zinc-800/50 rounded p-4 font-mono")], [ 112 - render_time_range_buttons(model.range), 113 - render_chart(model.data, model.range), 114 - ]), 115 - ]) 116 - } 117 - 118 - fn get_button_class(current_range: TimeRange, range: TimeRange) -> String { 119 - let base = "px-3 py-1 text-xs rounded transition-colors" 120 - case range == current_range { 121 - True -> base <> " bg-zinc-700 text-zinc-100" 122 - False -> base <> " bg-zinc-800/50 text-zinc-400 hover:bg-zinc-700/50 hover:text-zinc-300" 123 - } 124 - } 125 - 126 - fn render_time_range_buttons(current_range: TimeRange) -> Element(Msg) { 127 - let button = fn(range: TimeRange, label: String) { 128 - html.button( 129 - [ 130 - attribute.class(get_button_class(current_range, range)), 131 - event.on_click(ChangeRange(range)), 132 - ], 133 - [element.text(label)], 134 - ) 135 - } 136 - 137 - html.div([attribute.class("flex gap-2 mb-4")], [ 138 - button(OneHour, "1hr"), 139 - button(ThreeHour, "3hr"), 140 - button(SixHour, "6hr"), 141 - button(OneDay, "1 day"), 142 - button(SevenDay, "7 day"), 143 - ]) 144 - } 145 - 146 - fn render_time_range_buttons_static(current_range: TimeRange) -> Element(msg) { 147 - let button = fn(range: TimeRange, label: String) { 148 - html.button( 149 - [attribute.class(get_button_class(current_range, range))], 150 - [element.text(label)], 151 - ) 152 - } 153 - 154 - html.div([attribute.class("flex gap-2 mb-4")], [ 155 - button(OneHour, "1hr"), 156 - button(ThreeHour, "3hr"), 157 - button(SixHour, "6hr"), 158 - button(OneDay, "1 day"), 159 - button(SevenDay, "7 day"), 160 - ]) 161 - } 162 - 163 - fn render_chart(data: List(ActivityBucket), range: TimeRange) -> Element(msg) { 164 - case data { 165 - [] -> { 166 - html.div([attribute.class("py-8 text-center text-zinc-600 text-xs")], [ 167 - element.text("No activity data available"), 168 - ]) 169 - } 170 - buckets -> { 171 - let max_value = calculate_max_value(buckets) 172 - let #(bar_width, gap) = case range { 173 - SevenDay -> #(160.0, 12.0) 174 - _ -> #(30.0, 4.0) 175 - } 176 - let num_buckets = list.length(buckets) 177 - // Width = (num_bars * bar_width) + ((num_bars - 1) * gap) 178 - let chart_width = int.to_float(num_buckets) *. bar_width +. int.to_float(num_buckets - 1) *. gap 179 - let chart_height = 120.0 180 - 181 - html.div([attribute.class("w-full")], [ 182 - svg.svg( 183 - [ 184 - attribute.attribute("viewBox", "0 0 " <> float.to_string(chart_width) <> " " <> float.to_string(chart_height)), 185 - attribute.attribute("width", "100%"), 186 - attribute.attribute("height", float.to_string(chart_height)), 187 - attribute.attribute("style", "min-height: 120px"), 188 - attribute.attribute("preserveAspectRatio", "none"), 189 - ], 190 - list.index_map(buckets, fn(bucket, index) { 191 - render_stacked_bar(bucket, index, bar_width, gap, chart_height, max_value) 192 - }), 193 - ), 194 - ]) 195 - } 196 - } 197 - } 198 - 199 - fn calculate_max_value(buckets: List(ActivityBucket)) -> Int { 200 - buckets 201 - |> list.map(fn(b) { b.create_count + b.update_count + b.delete_count }) 202 - |> list.reduce(int.max) 203 - |> result.unwrap(1) 204 - } 205 - 206 - fn render_stacked_bar( 207 - bucket: ActivityBucket, 208 - index: Int, 209 - bar_width: Float, 210 - gap: Float, 211 - chart_height: Float, 212 - max_value: Int, 213 - ) -> Element(msg) { 214 - let x = int.to_float(index) *. { bar_width +. gap } 215 - let total = bucket.create_count + bucket.update_count + bucket.delete_count 216 - 217 - case total { 218 - 0 -> { 219 - // Render placeholder bar for empty bins 220 - let placeholder_height = 4.0 221 - let placeholder_y = chart_height -. placeholder_height 222 - svg.g( 223 - [ 224 - attribute.class("group"), 225 - attribute.attribute("data-tooltip-timestamp", bucket.timestamp), 226 - attribute.attribute("data-create", "0"), 227 - attribute.attribute("data-update", "0"), 228 - attribute.attribute("data-delete", "0"), 229 - ], 230 - [ 231 - svg.rect([ 232 - attribute.attribute("x", float.to_string(x)), 233 - attribute.attribute("y", float.to_string(placeholder_y)), 234 - attribute.attribute("width", float.to_string(bar_width)), 235 - attribute.attribute("height", float.to_string(placeholder_height)), 236 - attribute.attribute("style", "fill: #3f3f46 !important; stroke: none; display: inline; cursor: pointer"), 237 - attribute.class("group-hover:fill-zinc-700"), 238 - ]) 239 - ], 240 - ) 241 - } 242 - _ -> { 243 - let scale = chart_height /. int.to_float(max_value) 244 - 245 - // Calculate heights for each segment 246 - let delete_height = int.to_float(bucket.delete_count) *. scale 247 - let update_height = int.to_float(bucket.update_count) *. scale 248 - let create_height = int.to_float(bucket.create_count) *. scale 249 - 250 - // Calculate y positions (bottom to top: delete, update, create) 251 - let delete_y = chart_height -. delete_height 252 - let update_y = delete_y -. update_height 253 - let create_y = update_y -. create_height 254 - 255 - svg.g( 256 - [ 257 - attribute.class("group"), 258 - attribute.attribute("data-tooltip-timestamp", bucket.timestamp), 259 - attribute.attribute("data-create", int.to_string(bucket.create_count)), 260 - attribute.attribute("data-update", int.to_string(bucket.update_count)), 261 - attribute.attribute("data-delete", int.to_string(bucket.delete_count)), 262 - ], 263 - [ 264 - // Delete segment (red) - bottom 265 - case bucket.delete_count > 0 { 266 - True -> 267 - svg.rect([ 268 - attribute.attribute("x", float.to_string(x)), 269 - attribute.attribute("y", float.to_string(delete_y)), 270 - attribute.attribute("width", float.to_string(bar_width)), 271 - attribute.attribute("height", float.to_string(delete_height)), 272 - attribute.attribute("style", "fill: #ef4444 !important; stroke: none; display: inline; cursor: pointer; transition: opacity 0.2s"), 273 - attribute.class("group-hover:opacity-80"), 274 - ]) 275 - False -> element.none() 276 - }, 277 - // Update segment (blue) - middle 278 - case bucket.update_count > 0 { 279 - True -> 280 - svg.rect([ 281 - attribute.attribute("x", float.to_string(x)), 282 - attribute.attribute("y", float.to_string(update_y)), 283 - attribute.attribute("width", float.to_string(bar_width)), 284 - attribute.attribute("height", float.to_string(update_height)), 285 - attribute.attribute("style", "fill: #60a5fa !important; stroke: none; display: inline; cursor: pointer; transition: opacity 0.2s"), 286 - attribute.class("group-hover:opacity-80"), 287 - ]) 288 - False -> element.none() 289 - }, 290 - // Create segment (green) - top 291 - case bucket.create_count > 0 { 292 - True -> 293 - svg.rect([ 294 - attribute.attribute("x", float.to_string(x)), 295 - attribute.attribute("y", float.to_string(create_y)), 296 - attribute.attribute("width", float.to_string(bar_width)), 297 - attribute.attribute("height", float.to_string(create_height)), 298 - attribute.attribute("style", "fill: #22c55e !important; stroke: none; display: inline; cursor: pointer; transition: opacity 0.2s"), 299 - attribute.class("group-hover:opacity-80"), 300 - ]) 301 - False -> element.none() 302 - }, 303 - ], 304 - ) 305 - } 306 - } 307 - } 308 - 309 - 310 - // EFFECTS 311 - 312 - fn start_listening_in_background() -> effect.Effect(Msg) { 313 - use dispatch <- effect.from 314 - 315 - // Spawn a single long-running process to listen for stats events 316 - let _ = 317 - process.spawn_unlinked(fn() { 318 - // Subscribe in THIS process, not the component process 319 - let subscriber = stats_pubsub.subscribe() 320 - listen_loop(subscriber, dispatch) 321 - }) 322 - 323 - Nil 324 - } 325 - 326 - fn listen_loop( 327 - subscriber: process.Subject(stats_pubsub.StatsEvent), 328 - dispatch: fn(Msg) -> Nil, 329 - ) -> Nil { 330 - let selector = process.new_selector() |> process.select(subscriber) 331 - 332 - let event = process.selector_receive_forever(selector) 333 - dispatch(StatsEventReceived(event)) 334 - // Keep listening 335 - listen_loop(subscriber, dispatch) 336 - } 337 -
-88
server/src/components/alert.gleam
··· 1 - import gleam/option.{type Option} 2 - import lustre/attribute 3 - import lustre/element.{type Element} 4 - import lustre/element/html 5 - 6 - pub type AlertKind { 7 - Success 8 - Error 9 - Info 10 - Warning 11 - } 12 - 13 - /// Render an alert message with appropriate styling 14 - pub fn alert(kind: AlertKind, message: String) -> Element(msg) { 15 - let #(bg_class, border_class, text_class) = case kind { 16 - Success -> #("bg-green-900/30", "border-green-800", "text-green-300") 17 - Error -> #("bg-red-900/30", "border-red-800", "text-red-300") 18 - Info -> #("bg-blue-900/30", "border-blue-800", "text-blue-300") 19 - Warning -> #("bg-yellow-900/30", "border-yellow-800", "text-yellow-300") 20 - } 21 - 22 - html.div( 23 - [ 24 - attribute.class( 25 - "mb-6 p-4 rounded border " <> bg_class <> " " <> border_class, 26 - ), 27 - ], 28 - [ 29 - html.span([attribute.class("text-sm " <> text_class)], [ 30 - element.text(message), 31 - ]), 32 - ], 33 - ) 34 - } 35 - 36 - /// Render an alert message with a link 37 - pub fn alert_with_link( 38 - kind: AlertKind, 39 - message: String, 40 - link_text: String, 41 - link_url: String, 42 - ) -> Element(msg) { 43 - let #(bg_class, border_class, text_class) = case kind { 44 - Success -> #("bg-green-900/30", "border-green-800", "text-green-300") 45 - Error -> #("bg-red-900/30", "border-red-800", "text-red-300") 46 - Info -> #("bg-blue-900/30", "border-blue-800", "text-blue-300") 47 - Warning -> #("bg-yellow-900/30", "border-yellow-800", "text-yellow-300") 48 - } 49 - 50 - html.div( 51 - [ 52 - attribute.class( 53 - "mb-6 p-4 rounded border " <> bg_class <> " " <> border_class, 54 - ), 55 - ], 56 - [ 57 - html.span([attribute.class("text-sm " <> text_class)], [ 58 - element.text(message <> " "), 59 - html.a( 60 - [ 61 - attribute.href(link_url), 62 - attribute.class("underline hover:no-underline"), 63 - ], 64 - [element.text(link_text)], 65 - ), 66 - ]), 67 - ], 68 - ) 69 - } 70 - 71 - /// Helper to conditionally render alert based on optional kind and message 72 - pub fn maybe_alert( 73 - kind: Option(String), 74 - message: Option(String), 75 - ) -> Element(msg) { 76 - case kind, message { 77 - option.Some(k), option.Some(m) -> { 78 - let alert_kind = case k { 79 - "success" -> Success 80 - "error" -> Error 81 - "warning" -> Warning 82 - _ -> Info 83 - } 84 - alert(alert_kind, m) 85 - } 86 - _, _ -> element.none() 87 - } 88 - }
-231
server/src/components/backfill_button.gleam
··· 1 - import backfill 2 - import backfill_state 3 - import components/button 4 - import config 5 - import database 6 - import gleam/erlang/process 7 - import gleam/json 8 - import gleam/list 9 - import gleam/option 10 - import gleam/otp/actor 11 - import lustre 12 - import lustre/attribute 13 - import lustre/effect 14 - import lustre/element.{type Element} 15 - import lustre/element/html 16 - import lustre/event 17 - import sqlight 18 - 19 - // APP 20 - 21 - pub fn component( 22 - db: sqlight.Connection, 23 - backfill_state_subject: process.Subject(backfill_state.Message), 24 - config_subject: process.Subject(config.Message), 25 - ) { 26 - lustre.application( 27 - init(db, backfill_state_subject, config_subject, _), 28 - update, 29 - view, 30 - ) 31 - } 32 - 33 - // MODEL 34 - 35 - pub type Model { 36 - Model( 37 - backfilling: Bool, 38 - is_admin: Bool, 39 - db: sqlight.Connection, 40 - backfill_state: process.Subject(backfill_state.Message), 41 - config: process.Subject(config.Message), 42 - ) 43 - } 44 - 45 - fn init( 46 - db: sqlight.Connection, 47 - backfill_state_subject: process.Subject(backfill_state.Message), 48 - config_subject: process.Subject(config.Message), 49 - flags: #(Bool, Bool), 50 - ) -> #(Model, effect.Effect(Msg)) { 51 - let #(is_admin, backfilling) = flags 52 - let initial_effect = case backfilling { 53 - True -> start_polling() 54 - False -> effect.none() 55 - } 56 - 57 - #( 58 - Model( 59 - backfilling: backfilling, 60 - is_admin: is_admin, 61 - db: db, 62 - backfill_state: backfill_state_subject, 63 - config: config_subject, 64 - ), 65 - initial_effect, 66 - ) 67 - } 68 - 69 - // UPDATE 70 - 71 - pub opaque type Msg { 72 - UserClickedBackfill 73 - CheckBackfillState 74 - } 75 - 76 - fn update(model: Model, msg: Msg) -> #(Model, effect.Effect(Msg)) { 77 - case msg { 78 - UserClickedBackfill -> #( 79 - Model(..model, backfilling: True), 80 - effect.batch([ 81 - do_backfill(model.db, model.backfill_state, model.config), 82 - start_polling(), 83 - ]), 84 - ) 85 - 86 - CheckBackfillState -> { 87 - // Query the global backfill state 88 - let backfilling = 89 - actor.call( 90 - model.backfill_state, 91 - waiting: 100, 92 - sending: backfill_state.IsBackfilling, 93 - ) 94 - 95 - // Update model and continue polling only if still backfilling 96 - case backfilling, model.backfilling { 97 - // Still backfilling - continue polling 98 - True, _ -> #(Model(..model, backfilling: True), start_polling()) 99 - // Just completed (was backfilling, now not) - emit event to reload page 100 - False, True -> #( 101 - Model(..model, backfilling: False), 102 - event.emit("backfill-complete", json.null()), 103 - ) 104 - // Was already not backfilling - do nothing 105 - False, False -> #(model, effect.none()) 106 - } 107 - } 108 - } 109 - } 110 - 111 - // EFFECTS 112 - 113 - fn do_backfill( 114 - db: sqlight.Connection, 115 - backfill_state_subject: process.Subject(backfill_state.Message), 116 - config_subject: process.Subject(config.Message), 117 - ) -> effect.Effect(Msg) { 118 - effect.from(fn(_dispatch) { 119 - // Update global state to indicate backfill is starting 120 - process.send(backfill_state_subject, backfill_state.StartBackfill) 121 - 122 - // Get domain authority from config 123 - let domain_authority = case config.get_domain_authority(config_subject) { 124 - option.Some(authority) -> authority 125 - option.None -> "" 126 - } 127 - 128 - // Spawn async process to run backfill without blocking the UI 129 - let _ = 130 - process.spawn_unlinked(fn() { 131 - // Run the backfill 132 - case database.get_record_type_lexicons(db) { 133 - Ok(lexicons) -> { 134 - let #(collections, external_collections) = 135 - lexicons 136 - |> list.partition(fn(lex) { 137 - backfill.nsid_matches_domain_authority(lex.id, domain_authority) 138 - }) 139 - 140 - let collection_ids = list.map(collections, fn(lex) { lex.id }) 141 - let external_collection_ids = 142 - list.map(external_collections, fn(lex) { lex.id }) 143 - 144 - let config = backfill.default_config() 145 - 146 - // Run backfill (this will take time) 147 - let _ = 148 - backfill.backfill_collections( 149 - [], 150 - collection_ids, 151 - external_collection_ids, 152 - config, 153 - db, 154 - ) 155 - 156 - // Backfill is complete, update global state 157 - process.send(backfill_state_subject, backfill_state.StopBackfill) 158 - } 159 - Error(_) -> { 160 - // No lexicons, stop backfill immediately 161 - process.send(backfill_state_subject, backfill_state.StopBackfill) 162 - } 163 - } 164 - }) 165 - 166 - Nil 167 - }) 168 - } 169 - 170 - fn start_polling() -> effect.Effect(Msg) { 171 - use dispatch <- effect.from 172 - 173 - // Spawn a process that waits 2 seconds then dispatches CheckBackfillState 174 - let _ = 175 - process.spawn_unlinked(fn() { 176 - process.sleep(2000) 177 - dispatch(CheckBackfillState) 178 - }) 179 - 180 - Nil 181 - } 182 - 183 - // VIEW 184 - 185 - /// Renders the backfill button (static version for slot content) 186 - pub fn render_button_static(is_admin: Bool, backfilling: Bool) -> Element(msg) { 187 - case is_admin { 188 - False -> element.none() 189 - True -> { 190 - let button_text = case backfilling { 191 - True -> "Backfilling..." 192 - False -> "Backfill Collections" 193 - } 194 - 195 - html.div([attribute.class("inline font-mono")], [ 196 - button.button_static(disabled: backfilling, text: button_text), 197 - ]) 198 - } 199 - } 200 - } 201 - 202 - fn view(model: Model) -> Element(Msg) { 203 - case model.is_admin { 204 - False -> element.none() 205 - True -> { 206 - let button_text = case model.backfilling { 207 - True -> "Backfilling..." 208 - False -> "Backfill Collections" 209 - } 210 - 211 - html.div([attribute.class("font-mono")], [ 212 - // Include Tailwind styles in the Shadow DOM 213 - element.element( 214 - "link", 215 - [ 216 - attribute.attribute("rel", "stylesheet"), 217 - attribute.attribute("href", "/styles.css"), 218 - ], 219 - [], 220 - ), 221 - html.div([attribute.class("inline")], [ 222 - button.button( 223 - disabled: model.backfilling, 224 - on_click: UserClickedBackfill, 225 - text: button_text, 226 - ), 227 - ]), 228 - ]) 229 - } 230 - } 231 - }
+12 -12
server/src/components/button.gleam client/src/components/button.gleam
··· 23 23 ) 24 24 } 25 25 26 - /// Render a static button (no event handler, for slot content) 27 - pub fn button_static(disabled disabled: Bool, text text: String) -> Element(msg) { 28 - html.button( 26 + /// Render a link styled as a button (for SPA routes) 27 + pub fn link(href href: String, text text: String) -> Element(msg) { 28 + html.a([attribute.href(href), attribute.class(button_classes)], [ 29 + html.text(text), 30 + ]) 31 + } 32 + 33 + /// Render an external link styled as a button (opens in current tab, navigates away from SPA) 34 + pub fn external_link(href href: String, text text: String) -> Element(msg) { 35 + html.a( 29 36 [ 30 - attribute.type_("button"), 37 + attribute.href(href), 31 38 attribute.class(button_classes), 32 - attribute.disabled(disabled), 39 + attribute.attribute("rel", "noopener noreferrer"), 33 40 ], 34 41 [html.text(text)], 35 42 ) 36 43 } 37 - 38 - /// Render a link styled as a button 39 - pub fn link(href href: String, text text: String) -> Element(msg) { 40 - html.a([attribute.href(href), attribute.class(button_classes)], [ 41 - html.text(text), 42 - ]) 43 - }
server/src/components/input.gleam client/src/components/input.gleam
-331
server/src/components/jetstream_activity_log.gleam
··· 1 - import gleam/erlang/process 2 - import gleam/int 3 - import gleam/list 4 - import gleam/option 5 - import gleam/result 6 - import gleam/string 7 - import jetstream_activity.{type ActivityEntry} 8 - import lustre 9 - import lustre/attribute 10 - import lustre/effect 11 - import lustre/element.{type Element} 12 - import lustre/element/html 13 - import sqlight 14 - import stats_pubsub 15 - 16 - // APP 17 - 18 - pub fn component(db: sqlight.Connection) { 19 - lustre.application(init(db, _), update, view) 20 - } 21 - 22 - // MODEL 23 - 24 - pub type Model { 25 - Model( 26 - db: sqlight.Connection, 27 - activities: List(ActivityEntry), 28 - stats_subscriber: process.Subject(stats_pubsub.StatsEvent), 29 - ) 30 - } 31 - 32 - fn init(db: sqlight.Connection, _flags: Nil) -> #(Model, effect.Effect(Msg)) { 33 - // Get initial activity from database (last 24 hours) 34 - let activities = 35 - jetstream_activity.get_recent_activity(db, 24) |> result.unwrap([]) 36 - 37 - // We'll subscribe in the listener process, so create a dummy subject here 38 - let dummy_subscriber = process.new_subject() 39 - 40 - #( 41 - Model(db: db, activities: activities, stats_subscriber: dummy_subscriber), 42 - start_listening_in_background(), 43 - ) 44 - } 45 - 46 - // UPDATE 47 - 48 - pub opaque type Msg { 49 - StatsEventReceived(stats_pubsub.StatsEvent) 50 - } 51 - 52 - fn update(model: Model, msg: Msg) -> #(Model, effect.Effect(Msg)) { 53 - case msg { 54 - StatsEventReceived(event) -> { 55 - case event { 56 - stats_pubsub.ActivityLogged( 57 - id, 58 - timestamp, 59 - operation, 60 - collection, 61 - did, 62 - status, 63 - error_message, 64 - event_json, 65 - ) -> { 66 - // Add new activity to the beginning of the list 67 - let new_activity = 68 - jetstream_activity.ActivityEntry( 69 - id: id, 70 - timestamp: timestamp, 71 - operation: operation, 72 - collection: collection, 73 - did: did, 74 - status: status, 75 - error_message: error_message, 76 - event_json: event_json, 77 - ) 78 - 79 - // Prepend new activity and limit to 100 entries for UI performance 80 - let updated_activities = 81 - [new_activity, ..model.activities] |> list.take(100) 82 - 83 - #(Model(..model, activities: updated_activities), effect.none()) 84 - } 85 - // Ignore other stats events 86 - _ -> #(model, effect.none()) 87 - } 88 - } 89 - } 90 - } 91 - 92 - // EFFECTS 93 - 94 - fn start_listening_in_background() -> effect.Effect(Msg) { 95 - use dispatch <- effect.from 96 - 97 - // Spawn a single long-running process to listen for stats events 98 - let _ = 99 - process.spawn_unlinked(fn() { 100 - // Subscribe in THIS process, not the component process 101 - let subscriber = stats_pubsub.subscribe() 102 - listen_loop(subscriber, dispatch) 103 - }) 104 - 105 - Nil 106 - } 107 - 108 - fn listen_loop( 109 - subscriber: process.Subject(stats_pubsub.StatsEvent), 110 - dispatch: fn(Msg) -> Nil, 111 - ) -> Nil { 112 - let selector = process.new_selector() |> process.select(subscriber) 113 - 114 - let event = process.selector_receive_forever(selector) 115 - dispatch(StatsEventReceived(event)) 116 - // Keep listening 117 - listen_loop(subscriber, dispatch) 118 - } 119 - 120 - // VIEW 121 - 122 - /// Render static activity log for initial page load (before WebSocket connects) 123 - pub fn render_static(activities: List(ActivityEntry)) -> Element(msg) { 124 - render_activity_log(activities, True) 125 - } 126 - 127 - /// Shared activity log renderer 128 - fn render_activity_log(activities: List(ActivityEntry), static: Bool) -> Element(msg) { 129 - html.div([attribute.class("font-mono mb-8")], [ 130 - html.div([attribute.class("bg-zinc-800/50 rounded p-4")], [ 131 - // Header 132 - html.div( 133 - [attribute.class("flex items-center justify-between mb-3")], 134 - [ 135 - html.div([attribute.class("text-sm text-zinc-500")], [ 136 - element.text("JetStream Activity"), 137 - ]), 138 - html.span([attribute.class("text-xs text-zinc-600")], [ 139 - element.text(int.to_string(list.length(activities)) <> " events (24h)"), 140 - ]), 141 - ], 142 - ), 143 - // Activity list - scrollable 144 - html.div( 145 - [attribute.class("max-h-80 overflow-y-auto")], 146 - [ 147 - case activities { 148 - [] -> 149 - html.div([attribute.class("py-8 text-center text-zinc-600 text-xs")], [ 150 - element.text("No activity in the last 24 hours"), 151 - ]) 152 - activities -> html.div([], list.map(activities, render_activity_entry(_, static))) 153 - }, 154 - ], 155 - ), 156 - ]), 157 - ]) 158 - } 159 - 160 - /// Renders a single activity entry (compact Grafana-style with expandable details) 161 - fn render_activity_entry(entry: ActivityEntry, static: Bool) -> Element(msg) { 162 - let status_color = case entry.status { 163 - "success" -> "text-green-500" 164 - "validation_error" -> "text-yellow-500" 165 - "error" -> "text-red-500" 166 - "processing" -> "text-blue-500" 167 - _ -> "text-zinc-500" 168 - } 169 - 170 - let status_icon = case entry.status { 171 - "success" -> "✓" 172 - "validation_error" -> "⚠" 173 - "error" -> "✗" 174 - "processing" -> "⋯" 175 - _ -> "•" 176 - } 177 - 178 - let operation_color = case entry.operation { 179 - "create" -> "text-green-400" 180 - "update" -> "text-blue-400" 181 - "delete" -> "text-red-400" 182 - _ -> "text-zinc-400" 183 - } 184 - 185 - let entry_id = "activity-" <> int.to_string(entry.id) 186 - 187 - html.div( 188 - [ 189 - attribute.class("border-l-2 border-zinc-700/50 hover:border-zinc-600 transition-colors"), 190 - attribute.attribute("data-entry-id", entry_id), 191 - ], 192 - [ 193 - // Main log line 194 - html.div( 195 - [ 196 - attribute.class("flex items-start gap-2 py-1 text-xs font-mono hover:bg-zinc-900/30 cursor-pointer group"), 197 - attribute.attribute("onclick", "this.parentElement.classList.toggle('expanded')"), 198 - ], 199 - [ 200 - // Caret for expansion (always visible) 201 - html.span( 202 - [ 203 - attribute.class("text-zinc-600 group-hover:text-zinc-400 shrink-0 select-none transition-transform caret"), 204 - attribute.attribute("data-caret", ""), 205 - ], 206 - [element.text("›")], 207 - ), 208 - // Timestamp - formatted server-side for static, client-side for dynamic 209 - html.span( 210 - [ 211 - attribute.class("text-zinc-600 shrink-0 w-16"), 212 - attribute.attribute("data-timestamp", entry.timestamp), 213 - ], 214 - [element.text(case static { 215 - True -> format_time_only(entry.timestamp) 216 - False -> entry.timestamp 217 - })], 218 - ), 219 - // Status icon 220 - html.span([attribute.class(status_color <> " shrink-0 w-4")], [ 221 - element.text(status_icon), 222 - ]), 223 - // Operation 224 - html.span([attribute.class(operation_color <> " shrink-0 w-12")], [ 225 - element.text(entry.operation), 226 - ]), 227 - // Collection 228 - html.span([attribute.class("text-purple-400 shrink-0")], [ 229 - element.text(entry.collection), 230 - ]), 231 - // DID 232 - html.span([attribute.class("text-zinc-500 truncate")], [ 233 - element.text(entry.did), 234 - ]), 235 - ], 236 - ), 237 - // Expanded details section - shows all fields and JSON snippet 238 - html.div( 239 - [ 240 - attribute.class("px-6 py-2 text-xs bg-zinc-900/50 border-t border-zinc-800 hidden space-y-1"), 241 - attribute.attribute("data-details", ""), 242 - ], 243 - [ 244 - // Full timestamp 245 - html.div([attribute.class("flex gap-2")], [ 246 - html.span([attribute.class("text-zinc-600 w-20")], [element.text("Timestamp:")]), 247 - html.span([attribute.class("text-zinc-400")], [element.text(entry.timestamp)]), 248 - ]), 249 - // Full DID 250 - html.div([attribute.class("flex gap-2")], [ 251 - html.span([attribute.class("text-zinc-600 w-20")], [element.text("DID:")]), 252 - html.span([attribute.class("text-zinc-400 font-mono break-all")], [element.text(entry.did)]), 253 - ]), 254 - // Status 255 - html.div([attribute.class("flex gap-2")], [ 256 - html.span([attribute.class("text-zinc-600 w-20")], [element.text("Status:")]), 257 - html.span([attribute.class(case entry.status { 258 - "success" -> "text-green-400" 259 - "validation_error" -> "text-yellow-400" 260 - "error" -> "text-red-400" 261 - _ -> "text-zinc-400" 262 - })], [element.text(entry.status)]), 263 - ]), 264 - // Error message (if present) 265 - case entry.error_message { 266 - option.Some(err_msg) -> 267 - html.div([attribute.class("flex gap-2")], [ 268 - html.span([attribute.class("text-zinc-600 w-20")], [element.text("Error:")]), 269 - html.span([attribute.class("text-red-400")], [element.text(err_msg)]), 270 - ]) 271 - option.None -> element.none() 272 - }, 273 - // JSON snippet - will be formatted client-side 274 - html.div([attribute.class("mt-2")], [ 275 - html.div([attribute.class("text-zinc-600 mb-1")], [element.text("Event JSON:")]), 276 - html.pre( 277 - [ 278 - attribute.class("text-zinc-400 bg-black/40 p-2 rounded text-[10px] whitespace-pre-wrap block"), 279 - attribute.attribute("data-json", entry.event_json), 280 - ], 281 - [element.text(entry.event_json)], 282 - ), 283 - ]), 284 - ], 285 - ), 286 - ], 287 - ) 288 - } 289 - 290 - /// Extract time portion from ISO8601 timestamp (HH:MM:SS) 291 - /// This matches the format used by the client-side JavaScript formatter 292 - fn format_time_only(timestamp: String) -> String { 293 - // ISO8601 format: 2025-11-09T02:01:54.375Z 294 - // We want to extract: 02:01:54 295 - case string.split(timestamp, "T") { 296 - [_, time_part] -> { 297 - // time_part is like "02:01:54.375Z" 298 - case string.split(time_part, ".") { 299 - [time, _] -> time // Returns "02:01:54" 300 - _ -> timestamp // Fallback to full timestamp 301 - } 302 - } 303 - _ -> timestamp // Fallback to full timestamp 304 - } 305 - } 306 - 307 - fn view(model: Model) -> Element(Msg) { 308 - html.div([attribute.class("font-mono")], [ 309 - // Include Tailwind styles in the Shadow DOM 310 - element.element( 311 - "link", 312 - [ 313 - attribute.attribute("rel", "stylesheet"), 314 - attribute.attribute("href", "/styles.css"), 315 - ], 316 - [], 317 - ), 318 - // CSS for expandable details 319 - element.element( 320 - "style", 321 - [], 322 - [ 323 - element.text( 324 - "[data-entry-id].expanded [data-caret] { transform: rotate(90deg); } 325 - [data-entry-id].expanded [data-details] { display: block !important; }", 326 - ), 327 - ], 328 - ), 329 - render_activity_log(model.activities, False), 330 - ]) 331 - }
-425
server/src/components/layout.gleam
··· 1 - import components/logo 2 - import gleam/option.{type Option} 3 - import lustre/attribute 4 - import lustre/element.{type Element} 5 - import lustre/element/html 6 - 7 - /// Renders a complete HTML page with unified header 8 - pub fn page_with_header( 9 - title title: String, 10 - content content: List(Element(msg)), 11 - current_user current_user: Option(#(String, String)), 12 - domain_authority domain_authority: Option(String), 13 - ) -> Element(msg) { 14 - html.html([attribute.class("h-full")], [ 15 - head(title), 16 - html.body( 17 - [attribute.class("bg-zinc-950 text-zinc-300 font-mono min-h-screen")], 18 - [ 19 - html.div([attribute.class("max-w-4xl mx-auto px-6 py-12")], [ 20 - render_header(current_user, domain_authority), 21 - ..content 22 - ]), 23 - ], 24 - ), 25 - ]) 26 - } 27 - 28 - /// Renders a complete HTML page with the given title and content 29 - pub fn page( 30 - title title: String, 31 - content content: List(Element(msg)), 32 - ) -> Element(msg) { 33 - html.html([attribute.class("h-full")], [ 34 - head(title), 35 - body(content), 36 - ]) 37 - } 38 - 39 - /// Renders the HTML head with meta tags and styles 40 - fn head(title: String) -> Element(msg) { 41 - html.head([], [ 42 - html.title([], title), 43 - element.element("meta", [attribute.attribute("charset", "UTF-8")], []), 44 - element.element( 45 - "meta", 46 - [ 47 - attribute.attribute("name", "viewport"), 48 - attribute.attribute("content", "width=device-width, initial-scale=1.0"), 49 - ], 50 - [], 51 - ), 52 - element.element( 53 - "link", 54 - [ 55 - attribute.attribute("rel", "stylesheet"), 56 - attribute.attribute("href", "/styles.css"), 57 - ], 58 - [], 59 - ), 60 - // Tippy.js for tooltips 61 - html.script( 62 - [attribute.attribute("src", "https://unpkg.com/@popperjs/core@2")], 63 - "", 64 - ), 65 - html.script( 66 - [attribute.attribute("src", "https://unpkg.com/tippy.js@6")], 67 - "", 68 - ), 69 - element.element( 70 - "link", 71 - [ 72 - attribute.attribute("rel", "stylesheet"), 73 - attribute.attribute("href", "https://unpkg.com/tippy.js@6/themes/light.css"), 74 - ], 75 - [], 76 - ), 77 - // Lustre server component runtime 78 - html.script( 79 - [ 80 - attribute.type_("module"), 81 - attribute.attribute("src", "/lustre/runtime.mjs"), 82 - ], 83 - "", 84 - ), 85 - // Define custom elements for client-side formatting 86 - html.script( 87 - [], 88 - " 89 - // Format timestamps inside Shadow DOM 90 - function formatTimestamps(shadowRoot) { 91 - if (!shadowRoot) return; 92 - 93 - const timeElements = shadowRoot.querySelectorAll('[data-timestamp]'); 94 - timeElements.forEach(el => { 95 - const utcTime = el.getAttribute('data-timestamp'); 96 - if (utcTime) { 97 - try { 98 - const date = new Date(utcTime); 99 - const formatted = date.toLocaleTimeString([], { 100 - hour: '2-digit', 101 - minute: '2-digit', 102 - second: '2-digit', 103 - hour12: false 104 - }); 105 - // Always reformat - Lustre may reuse elements with different data 106 - if (el.textContent !== formatted) { 107 - el.textContent = formatted; 108 - } 109 - } catch (e) { 110 - console.error('[Timestamp Formatter] Error:', e); 111 - } 112 - } 113 - }); 114 - 115 - // Format JSON elements 116 - const jsonElements = shadowRoot.querySelectorAll('[data-json]'); 117 - jsonElements.forEach(el => { 118 - const jsonStr = el.getAttribute('data-json'); 119 - if (jsonStr) { 120 - try { 121 - const parsed = JSON.parse(jsonStr); 122 - if (parsed.commit && typeof parsed.commit.record === 'string') { 123 - try { 124 - parsed.commit.record = JSON.parse(parsed.commit.record); 125 - } catch (e) {} 126 - } 127 - const formatted = JSON.stringify(parsed, null, 2); 128 - if (el.textContent !== formatted) { 129 - el.textContent = formatted; 130 - } 131 - } catch (e) { 132 - console.error('[JSON Formatter] Error:', e); 133 - } 134 - } 135 - }); 136 - } 137 - 138 - // Listen for activity log component mount and updates 139 - document.addEventListener('DOMContentLoaded', function() { 140 - const activityLog = document.querySelector('lustre-server-component#activity-log'); 141 - 142 - if (activityLog) { 143 - // Format on initial mount 144 - activityLog.addEventListener('lustre:mount', function() { 145 - formatTimestamps(activityLog.shadowRoot); 146 - }); 147 - 148 - // Also try formatting immediately in case already mounted 149 - if (activityLog.shadowRoot) { 150 - formatTimestamps(activityLog.shadowRoot); 151 - } 152 - 153 - // Watch for changes in Shadow DOM using MutationObserver 154 - const observer = new MutationObserver(() => { 155 - formatTimestamps(activityLog.shadowRoot); 156 - }); 157 - 158 - // Wait a bit for shadow root to be available 159 - setTimeout(() => { 160 - if (activityLog.shadowRoot) { 161 - observer.observe(activityLog.shadowRoot, { 162 - childList: true, 163 - subtree: true 164 - }); 165 - // Format once more to catch anything that loaded during timeout 166 - formatTimestamps(activityLog.shadowRoot); 167 - } 168 - }, 100); 169 - } 170 - 171 - // Initialize tooltips for activity chart 172 - function initChartTooltips(shadowRoot) { 173 - if (!shadowRoot || !window.tippy) return; 174 - 175 - const bars = shadowRoot.querySelectorAll('[data-tooltip-timestamp]'); 176 - bars.forEach(bar => { 177 - // Destroy existing tippy instance if it exists 178 - if (bar._tippy) { 179 - bar._tippy.destroy(); 180 - } 181 - 182 - const timestamp = bar.getAttribute('data-tooltip-timestamp'); 183 - const create = bar.getAttribute('data-create') || '0'; 184 - const update = bar.getAttribute('data-update') || '0'; 185 - const del = bar.getAttribute('data-delete') || '0'; 186 - const total = parseInt(create) + parseInt(update) + parseInt(del); 187 - 188 - if (!timestamp) return; 189 - 190 - try { 191 - const date = new Date(timestamp); 192 - const formatted = date.toLocaleString([], { 193 - month: 'short', 194 - day: 'numeric', 195 - hour: 'numeric', 196 - minute: '2-digit', 197 - hour12: true 198 - }); 199 - const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; 200 - 201 - let content = formatted + ' (' + timezone + ')'; 202 - if (total === 0) { 203 - content += '\\nNo activity'; 204 - } else { 205 - content += '\\nCreate: ' + create; 206 - content += '\\nUpdate: ' + update; 207 - content += '\\nDelete: ' + del; 208 - content += '\\nTotal: ' + total; 209 - } 210 - 211 - tippy(bar, { 212 - content: content.replace(/\\n/g, '<br>'), 213 - allowHTML: true, 214 - theme: 'dark', 215 - placement: 'top' 216 - }); 217 - } catch (e) { 218 - console.error('[Tooltip] Error:', e); 219 - } 220 - }); 221 - } 222 - 223 - const activityChart = document.querySelector('lustre-server-component#activity-chart'); 224 - if (activityChart) { 225 - activityChart.addEventListener('lustre:mount', function() { 226 - initChartTooltips(activityChart.shadowRoot); 227 - }); 228 - 229 - if (activityChart.shadowRoot) { 230 - initChartTooltips(activityChart.shadowRoot); 231 - } 232 - 233 - const chartObserver = new MutationObserver(() => { 234 - initChartTooltips(activityChart.shadowRoot); 235 - }); 236 - 237 - setTimeout(() => { 238 - if (activityChart.shadowRoot) { 239 - chartObserver.observe(activityChart.shadowRoot, { 240 - childList: true, 241 - subtree: true 242 - }); 243 - initChartTooltips(activityChart.shadowRoot); 244 - } 245 - }, 100); 246 - } 247 - 248 - // Listen for backfill-complete event and reload page 249 - const backfillButton = document.querySelector('lustre-server-component#backfill-button'); 250 - if (backfillButton) { 251 - backfillButton.addEventListener('backfill-complete', function() { 252 - window.location.reload(); 253 - }); 254 - } 255 - }); 256 - ", 257 - ), 258 - ]) 259 - } 260 - 261 - /// Renders the HTML body with a max-width container and navigation 262 - fn body(content: List(Element(msg))) -> Element(msg) { 263 - html.body( 264 - [attribute.class("bg-zinc-950 text-zinc-300 font-mono min-h-screen")], 265 - [ 266 - nav_header(), 267 - html.div([attribute.class("max-w-4xl mx-auto px-6 py-12")], content), 268 - ], 269 - ) 270 - } 271 - 272 - /// Renders the navigation header 273 - fn nav_header() -> Element(msg) { 274 - html.nav([attribute.class("bg-zinc-900 border-b border-zinc-800")], [ 275 - html.div([attribute.class("max-w-4xl mx-auto px-6 py-4")], [ 276 - html.div([attribute.class("flex items-center justify-between")], [ 277 - html.a( 278 - [ 279 - attribute.href("/"), 280 - attribute.class( 281 - "text-zinc-300 hover:text-zinc-100 transition-colors", 282 - ), 283 - ], 284 - [element.text("quickslice")], 285 - ), 286 - html.div([attribute.class("flex gap-4")], [ 287 - html.a( 288 - [ 289 - attribute.href("/"), 290 - attribute.class( 291 - "text-zinc-400 hover:text-zinc-200 transition-colors text-sm", 292 - ), 293 - ], 294 - [element.text("Home")], 295 - ), 296 - html.a( 297 - [ 298 - attribute.href("/settings"), 299 - attribute.class( 300 - "text-zinc-400 hover:text-zinc-200 transition-colors text-sm", 301 - ), 302 - ], 303 - [element.text("Settings")], 304 - ), 305 - ]), 306 - ]), 307 - ]), 308 - ]) 309 - } 310 - 311 - /// Renders the unified header with logo, nav links, and user section 312 - fn render_header( 313 - current_user: Option(#(String, String)), 314 - domain_authority: Option(String), 315 - ) -> Element(msg) { 316 - html.div([attribute.class("border-b border-zinc-800 pb-4 mb-8")], [ 317 - html.div([attribute.class("flex items-end justify-between")], [ 318 - // Left: Brand with logo 319 - html.a( 320 - [ 321 - attribute.href("/"), 322 - attribute.class( 323 - "flex items-center gap-3 hover:opacity-80 transition-opacity", 324 - ), 325 - ], 326 - [ 327 - logo.view("w-10 h-10"), 328 - html.div([], [ 329 - html.h1( 330 - [ 331 - attribute.class( 332 - "text-xs font-medium uppercase tracking-wider text-zinc-500", 333 - ), 334 - ], 335 - [element.text("quickslice")], 336 - ), 337 - case domain_authority { 338 - option.Some(value) -> 339 - case value { 340 - "" -> element.none() 341 - _ -> 342 - html.p([attribute.class("text-xs text-zinc-600 mt-1")], [ 343 - element.text(value), 344 - ]) 345 - } 346 - option.None -> element.none() 347 - }, 348 - ]), 349 - ], 350 - ), 351 - // Right: Navigation + User section 352 - case current_user { 353 - option.Some(_) -> { 354 - html.div([attribute.class("flex gap-4 text-xs items-center")], [ 355 - html.a( 356 - [ 357 - attribute.href("/"), 358 - attribute.class( 359 - "px-2 py-1 text-zinc-400 hover:text-zinc-300 transition-colors", 360 - ), 361 - ], 362 - [element.text("Home")], 363 - ), 364 - html.a( 365 - [ 366 - attribute.href("/settings"), 367 - attribute.class( 368 - "px-2 py-1 text-zinc-400 hover:text-zinc-300 transition-colors", 369 - ), 370 - ], 371 - [element.text("Settings")], 372 - ), 373 - render_user_section(current_user), 374 - ]) 375 - } 376 - option.None -> { 377 - html.div([attribute.class("flex items-center")], [ 378 - render_user_section(current_user), 379 - ]) 380 - } 381 - }, 382 - ]), 383 - ]) 384 - } 385 - 386 - /// Renders the user section showing login or user info 387 - fn render_user_section(current_user: Option(#(String, String))) -> Element(msg) { 388 - case current_user { 389 - option.Some(#(_did, handle)) -> { 390 - html.span([attribute.class("px-2 py-1 text-zinc-400")], [ 391 - element.text("@" <> handle), 392 - ]) 393 - } 394 - option.None -> { 395 - // Show inline login form 396 - html.form( 397 - [ 398 - attribute.method("post"), 399 - attribute.action("/oauth/authorize"), 400 - attribute.class("flex items-center gap-2"), 401 - ], 402 - [ 403 - html.input([ 404 - attribute.type_("text"), 405 - attribute.name("loginHint"), 406 - attribute.placeholder("your-handle.bsky.social"), 407 - attribute.class( 408 - "px-3 py-2 bg-zinc-900 border border-zinc-800 rounded text-xs text-zinc-300 focus:outline-none focus:border-zinc-700", 409 - ), 410 - attribute.attribute("required", ""), 411 - ]), 412 - html.button( 413 - [ 414 - attribute.type_("submit"), 415 - attribute.class( 416 - "px-3 py-2 text-xs text-zinc-300 bg-zinc-800 hover:bg-zinc-700 rounded transition-colors cursor-pointer", 417 - ), 418 - ], 419 - [element.text("Login")], 420 - ), 421 - ], 422 - ) 423 - } 424 - } 425 - }
server/src/components/logo.gleam client/src/components/logo.gleam
-36
server/src/components/stats_card.gleam
··· 1 - import gleam/int 2 - import lustre/attribute 3 - import lustre/element.{type Element} 4 - import lustre/element/html 5 - 6 - /// Renders a statistics card with a count and description 7 - pub fn card( 8 - count count: Int, 9 - description description: String, 10 - color color: String, 11 - ) -> Element(msg) { 12 - let bg_class = "bg-" <> color <> "-900/20" 13 - let border_class = "border-" <> color <> "-800" 14 - let text_class = "text-" <> color <> "-400" 15 - 16 - html.div( 17 - [ 18 - attribute.class( 19 - "bg-zinc-900 " 20 - <> bg_class 21 - <> " rounded-lg p-6 border " 22 - <> border_class 23 - <> " shadow-sm", 24 - ), 25 - ], 26 - [ 27 - html.div( 28 - [attribute.class("text-4xl font-bold " <> text_class <> " mb-2")], 29 - [ 30 - element.text(int.to_string(count)), 31 - ], 32 - ), 33 - html.div([attribute.class("text-zinc-400")], [element.text(description)]), 34 - ], 35 - ) 36 - }
-162
server/src/components/stats_cards.gleam
··· 1 - import database 2 - import format 3 - import gleam/erlang/process 4 - import gleam/int 5 - import gleam/result 6 - import lustre 7 - import lustre/attribute 8 - import lustre/effect 9 - import lustre/element.{type Element} 10 - import lustre/element/html 11 - import sqlight 12 - import stats_pubsub 13 - 14 - // APP 15 - 16 - pub fn component(db: sqlight.Connection) { 17 - lustre.application(init(db, _), update, view) 18 - } 19 - 20 - // MODEL 21 - 22 - pub type Model { 23 - Model( 24 - db: sqlight.Connection, 25 - record_count: Int, 26 - actor_count: Int, 27 - lexicon_count: Int, 28 - stats_subscriber: process.Subject(stats_pubsub.StatsEvent), 29 - ) 30 - } 31 - 32 - fn init(db: sqlight.Connection, _flags: Nil) -> #(Model, effect.Effect(Msg)) { 33 - // Get initial counts from database 34 - let record_count = database.get_record_count(db) |> result.unwrap(0) 35 - let actor_count = database.get_actor_count(db) |> result.unwrap(0) 36 - let lexicon_count = database.get_lexicon_count(db) |> result.unwrap(0) 37 - 38 - // We'll subscribe in the listener process, so create a dummy subject here 39 - let dummy_subscriber = process.new_subject() 40 - 41 - #( 42 - Model( 43 - db: db, 44 - record_count: record_count, 45 - actor_count: actor_count, 46 - lexicon_count: lexicon_count, 47 - stats_subscriber: dummy_subscriber, 48 - ), 49 - start_listening_in_background(), 50 - ) 51 - } 52 - 53 - // UPDATE 54 - 55 - pub opaque type Msg { 56 - StatsEventReceived(stats_pubsub.StatsEvent) 57 - } 58 - 59 - fn update(model: Model, msg: Msg) -> #(Model, effect.Effect(Msg)) { 60 - case msg { 61 - StatsEventReceived(event) -> { 62 - case event { 63 - stats_pubsub.RecordCreated -> { 64 - #(Model(..model, record_count: model.record_count + 1), effect.none()) 65 - } 66 - stats_pubsub.RecordDeleted -> { 67 - #( 68 - Model(..model, record_count: int.max(0, model.record_count - 1)), 69 - effect.none(), 70 - ) 71 - } 72 - stats_pubsub.ActorCreated -> { 73 - #(Model(..model, actor_count: model.actor_count + 1), effect.none()) 74 - } 75 - // Ignore activity logged events - those are for the activity log component 76 - stats_pubsub.ActivityLogged(..) -> #(model, effect.none()) 77 - } 78 - } 79 - } 80 - } 81 - 82 - // EFFECTS 83 - 84 - fn start_listening_in_background() -> effect.Effect(Msg) { 85 - use dispatch <- effect.from 86 - 87 - // Spawn a single long-running process to listen for stats events 88 - let _ = 89 - process.spawn_unlinked(fn() { 90 - // Subscribe in THIS process, not the component process 91 - let subscriber = stats_pubsub.subscribe() 92 - listen_loop(subscriber, dispatch) 93 - }) 94 - 95 - Nil 96 - } 97 - 98 - fn listen_loop( 99 - subscriber: process.Subject(stats_pubsub.StatsEvent), 100 - dispatch: fn(Msg) -> Nil, 101 - ) -> Nil { 102 - let selector = process.new_selector() |> process.select(subscriber) 103 - 104 - let event = process.selector_receive_forever(selector) 105 - dispatch(StatsEventReceived(event)) 106 - // Keep listening 107 - listen_loop(subscriber, dispatch) 108 - } 109 - 110 - // VIEW 111 - 112 - /// Renders the stats cards grid with the given counts 113 - pub fn render_stats_grid( 114 - record_count: Int, 115 - actor_count: Int, 116 - lexicon_count: Int, 117 - ) -> Element(msg) { 118 - html.div([attribute.class("mb-8 grid grid-cols-3 gap-4")], [ 119 - // Total records stat card 120 - html.div([attribute.class("bg-zinc-800/50 rounded p-4")], [ 121 - html.div([attribute.class("text-sm text-zinc-500 mb-1")], [ 122 - element.text("Total Records"), 123 - ]), 124 - html.div([attribute.class("text-2xl font-semibold text-zinc-200")], [ 125 - element.text(format.format_number(record_count)), 126 - ]), 127 - ]), 128 - // Actors stat card 129 - html.div([attribute.class("bg-zinc-800/50 rounded p-4")], [ 130 - html.div([attribute.class("text-sm text-zinc-500 mb-1")], [ 131 - element.text("Total Actors"), 132 - ]), 133 - html.div([attribute.class("text-2xl font-semibold text-zinc-200")], [ 134 - element.text(format.format_number(actor_count)), 135 - ]), 136 - ]), 137 - // Lexicons stat card (static for now) 138 - html.div([attribute.class("bg-zinc-800/50 rounded p-4")], [ 139 - html.div([attribute.class("text-sm text-zinc-500 mb-1")], [ 140 - element.text("Total Lexicons"), 141 - ]), 142 - html.div([attribute.class("text-2xl font-semibold text-zinc-200")], [ 143 - element.text(format.format_number(lexicon_count)), 144 - ]), 145 - ]), 146 - ]) 147 - } 148 - 149 - fn view(model: Model) -> Element(Msg) { 150 - html.div([attribute.class("font-mono")], [ 151 - // Include Tailwind styles in the Shadow DOM 152 - element.element( 153 - "link", 154 - [ 155 - attribute.attribute("rel", "stylesheet"), 156 - attribute.attribute("href", "/styles.css"), 157 - ], 158 - [], 159 - ), 160 - render_stats_grid(model.record_count, model.actor_count, model.lexicon_count), 161 - ]) 162 - }
+10
server/src/database.gleam
··· 510 510 |> result.map(fn(_) { Nil }) 511 511 } 512 512 513 + /// Deletes all jetstream activity logs from the database 514 + pub fn delete_all_jetstream_activity( 515 + conn: sqlight.Connection, 516 + ) -> Result(Nil, sqlight.Error) { 517 + let sql = "DELETE FROM jetstream_activity" 518 + 519 + sqlight.exec(sql, conn) 520 + |> result.map(fn(_) { Nil }) 521 + } 522 + 513 523 // ===== Record Functions ===== 514 524 515 525 /// Gets existing CIDs for a list of URIs
+107 -14
server/src/importer.gleam
··· 8 8 import logging 9 9 import simplifile 10 10 import sqlight 11 + import zip_helper 11 12 12 13 pub type ImportStats { 13 14 ImportStats(total: Int, imported: Int, failed: Int, errors: List(String)) ··· 125 126 Ok(entries) -> { 126 127 entries 127 128 |> list.filter_map(fn(entry) { 128 - let entry_path = path <> "/" <> entry 129 + // Skip macOS metadata directories and hidden files 130 + case 131 + string.starts_with(entry, "__MACOSX") 132 + || string.starts_with(entry, ".") 133 + { 134 + True -> Error(Nil) 135 + False -> { 136 + let entry_path = path <> "/" <> entry 129 137 130 - case simplifile.is_directory(entry_path) { 131 - Ok(True) -> { 132 - // Recursively scan subdirectory 133 - case scan_directory_recursive(entry_path) { 134 - Ok(paths) -> Ok(paths) 135 - Error(_) -> Error(Nil) 136 - } 137 - } 138 - _ -> { 139 - // Check if it's a .json file 140 - case string.ends_with(entry, ".json") { 141 - True -> Ok([entry_path]) 142 - False -> Error(Nil) 138 + case simplifile.is_directory(entry_path) { 139 + Ok(True) -> { 140 + // Recursively scan subdirectory 141 + case scan_directory_recursive(entry_path) { 142 + Ok(paths) -> Ok(paths) 143 + Error(_) -> Error(Nil) 144 + } 145 + } 146 + _ -> { 147 + // Check if it's a .json file 148 + case string.ends_with(entry, ".json") { 149 + True -> Ok([entry_path]) 150 + False -> Error(Nil) 151 + } 152 + } 143 153 } 144 154 } 145 155 } ··· 268 278 } 269 279 } 270 280 } 281 + 282 + /// Decode base64 string to bit array using Erlang FFI 283 + @external(erlang, "base64", "decode") 284 + fn decode_base64(base64: String) -> BitArray 285 + 286 + /// Import lexicons from a base64-encoded ZIP file 287 + /// Returns ImportStats on success, error message on failure 288 + pub fn import_lexicons_from_base64_zip( 289 + zip_base64: String, 290 + db: sqlight.Connection, 291 + ) -> Result(ImportStats, String) { 292 + // Decode base64 to binary 293 + let zip_binary = decode_base64(zip_base64) 294 + 295 + // Create temporary directory for extraction 296 + let temp_dir = "/tmp/lexicons_" <> string.inspect(erlang_timestamp()) 297 + use _ <- result.try(case simplifile.create_directory(temp_dir) { 298 + Ok(_) -> Ok(Nil) 299 + Error(_) -> Error("Failed to create temporary directory") 300 + }) 301 + 302 + // Write ZIP file to temp location 303 + let zip_path = temp_dir <> "/lexicons.zip" 304 + use _ <- result.try(case simplifile.write_bits(zip_path, zip_binary) { 305 + Ok(_) -> Ok(Nil) 306 + Error(_) -> Error("Failed to write ZIP file") 307 + }) 308 + 309 + // Extract ZIP to temp directory 310 + let extract_dir = temp_dir <> "/extracted" 311 + use _ <- result.try(case simplifile.create_directory(extract_dir) { 312 + Ok(_) -> Ok(Nil) 313 + Error(_) -> Error("Failed to create extraction directory") 314 + }) 315 + 316 + use _ <- result.try(zip_helper.extract_zip(zip_path, extract_dir)) 317 + 318 + // Import lexicons from extracted directory 319 + let import_result = import_lexicons_from_directory(extract_dir, db) 320 + 321 + // Clean up temp directory 322 + let _ = simplifile.delete(zip_path) 323 + let _ = delete_directory_recursive(extract_dir) 324 + let _ = simplifile.delete(temp_dir) 325 + 326 + import_result 327 + } 328 + 329 + /// Recursively delete a directory and its contents 330 + fn delete_directory_recursive(path: String) -> Result(Nil, Nil) { 331 + case simplifile.is_directory(path) { 332 + Ok(True) -> { 333 + case simplifile.read_directory(path) { 334 + Ok(entries) -> { 335 + // Delete all entries first 336 + list.each(entries, fn(entry) { 337 + let entry_path = path <> "/" <> entry 338 + let _ = delete_directory_recursive(entry_path) 339 + Nil 340 + }) 341 + 342 + // Then delete the directory itself 343 + case simplifile.delete(path) { 344 + Ok(_) -> Ok(Nil) 345 + Error(_) -> Error(Nil) 346 + } 347 + } 348 + Error(_) -> Error(Nil) 349 + } 350 + } 351 + _ -> { 352 + // Not a directory, try to delete as file 353 + case simplifile.delete(path) { 354 + Ok(_) -> Ok(Nil) 355 + Error(_) -> Error(Nil) 356 + } 357 + } 358 + } 359 + } 360 + 361 + /// Get current Erlang timestamp (for temp directory naming) 362 + @external(erlang, "erlang", "system_time") 363 + fn erlang_timestamp() -> Int
-401
server/src/lustre_handlers.gleam
··· 1 - /// WebSocket handlers and setup for Lustre server components. 2 - /// 3 - /// This module contains all the WebSocket lifecycle handlers and routing 4 - /// for Lustre server components, including serving the client runtime and 5 - /// managing component WebSocket connections. 6 - import backfill_state 7 - import components/activity_chart 8 - import components/backfill_button 9 - import components/jetstream_activity_log 10 - import components/stats_cards 11 - import config 12 - import gleam/bytes_tree 13 - import gleam/erlang/application 14 - import gleam/erlang/process 15 - import gleam/http/request 16 - import gleam/http/response 17 - import gleam/json 18 - import gleam/option 19 - import gleam/otp/actor 20 - import lustre 21 - import lustre/server_component 22 - import mist 23 - import sqlight 24 - 25 - // LUSTRE RUNTIME 26 - 27 - /// Serve the Lustre client runtime JavaScript 28 - pub fn serve_lustre_runtime() -> response.Response(mist.ResponseData) { 29 - let assert Ok(lustre_priv) = application.priv_directory("lustre") 30 - let file_path = lustre_priv <> "/static/lustre-server-component.mjs" 31 - 32 - case mist.send_file(file_path, offset: 0, limit: option.None) { 33 - Ok(file) -> 34 - response.new(200) 35 - |> response.prepend_header("content-type", "application/javascript") 36 - |> response.set_body(file) 37 - 38 - Error(_) -> 39 - response.new(404) 40 - |> response.set_body(mist.Bytes(bytes_tree.new())) 41 - } 42 - } 43 - 44 - /// Serve the Tailwind CSS file 45 - pub fn serve_tailwind_css() -> response.Response(mist.ResponseData) { 46 - let assert Ok(server_priv) = application.priv_directory("server") 47 - let file_path = server_priv <> "/static/styles.css" 48 - 49 - case mist.send_file(file_path, offset: 0, limit: option.None) { 50 - Ok(file) -> 51 - response.new(200) 52 - |> response.prepend_header("content-type", "text/css") 53 - |> response.set_body(file) 54 - 55 - Error(_) -> 56 - response.new(404) 57 - |> response.set_body(mist.Bytes(bytes_tree.new())) 58 - } 59 - } 60 - 61 - // BACKFILL BUTTON COMPONENT 62 - 63 - /// WebSocket handler for backfill button component 64 - pub fn serve_backfill_button( 65 - req: request.Request(mist.Connection), 66 - db: sqlight.Connection, 67 - backfill_state_subject: process.Subject(backfill_state.Message), 68 - config_subject: process.Subject(config.Message), 69 - ) -> response.Response(mist.ResponseData) { 70 - mist.websocket( 71 - request: req, 72 - on_init: init_backfill_button_socket( 73 - db, 74 - backfill_state_subject, 75 - config_subject, 76 - _, 77 - ), 78 - handler: loop_backfill_button_socket, 79 - on_close: close_backfill_button_socket, 80 - ) 81 - } 82 - 83 - type BackfillButtonSocket { 84 - BackfillButtonSocket( 85 - component: lustre.Runtime(backfill_button.Msg), 86 - self: process.Subject(server_component.ClientMessage(backfill_button.Msg)), 87 - ) 88 - } 89 - 90 - type BackfillButtonSocketMessage = 91 - server_component.ClientMessage(backfill_button.Msg) 92 - 93 - type BackfillButtonSocketInit = 94 - #( 95 - BackfillButtonSocket, 96 - option.Option(process.Selector(BackfillButtonSocketMessage)), 97 - ) 98 - 99 - fn init_backfill_button_socket( 100 - db: sqlight.Connection, 101 - backfill_state_subject: process.Subject(backfill_state.Message), 102 - config_subject: process.Subject(config.Message), 103 - _connection: mist.WebsocketConnection, 104 - ) -> BackfillButtonSocketInit { 105 - // TODO: Get is_admin from session 106 - let is_admin = True 107 - 108 - // Query current backfill state 109 - let backfilling = 110 - actor.call( 111 - backfill_state_subject, 112 - waiting: 100, 113 - sending: backfill_state.IsBackfilling, 114 - ) 115 - 116 - let component = 117 - backfill_button.component(db, backfill_state_subject, config_subject) 118 - let assert Ok(runtime) = 119 - lustre.start_server_component(component, #(is_admin, backfilling)) 120 - 121 - let self = process.new_subject() 122 - let selector = process.new_selector() |> process.select(self) 123 - 124 - server_component.register_subject(self) 125 - |> lustre.send(to: runtime) 126 - 127 - #(BackfillButtonSocket(component: runtime, self: self), option.Some(selector)) 128 - } 129 - 130 - fn loop_backfill_button_socket( 131 - state: BackfillButtonSocket, 132 - message: mist.WebsocketMessage(BackfillButtonSocketMessage), 133 - connection: mist.WebsocketConnection, 134 - ) -> mist.Next(BackfillButtonSocket, BackfillButtonSocketMessage) { 135 - case message { 136 - mist.Text(json_string) -> { 137 - case json.parse(json_string, server_component.runtime_message_decoder()) { 138 - Ok(runtime_message) -> lustre.send(state.component, runtime_message) 139 - Error(_) -> Nil 140 - } 141 - 142 - mist.continue(state) 143 - } 144 - 145 - mist.Binary(_) -> mist.continue(state) 146 - 147 - mist.Custom(client_message) -> { 148 - let json_obj = server_component.client_message_to_json(client_message) 149 - let assert Ok(_) = 150 - mist.send_text_frame(connection, json.to_string(json_obj)) 151 - 152 - mist.continue(state) 153 - } 154 - 155 - mist.Closed | mist.Shutdown -> mist.stop() 156 - } 157 - } 158 - 159 - fn close_backfill_button_socket(state: BackfillButtonSocket) -> Nil { 160 - lustre.shutdown() 161 - |> lustre.send(to: state.component) 162 - } 163 - 164 - // STATS CARDS COMPONENT 165 - 166 - /// WebSocket handler for stats cards component 167 - pub fn serve_stats_cards( 168 - req: request.Request(mist.Connection), 169 - db: sqlight.Connection, 170 - ) -> response.Response(mist.ResponseData) { 171 - mist.websocket( 172 - request: req, 173 - on_init: init_stats_cards_socket(db, _), 174 - handler: loop_stats_cards_socket, 175 - on_close: close_stats_cards_socket, 176 - ) 177 - } 178 - 179 - type StatsCardsSocket { 180 - StatsCardsSocket( 181 - component: lustre.Runtime(stats_cards.Msg), 182 - self: process.Subject(server_component.ClientMessage(stats_cards.Msg)), 183 - ) 184 - } 185 - 186 - type StatsCardsSocketMessage = 187 - server_component.ClientMessage(stats_cards.Msg) 188 - 189 - type StatsCardsSocketInit = 190 - #(StatsCardsSocket, option.Option(process.Selector(StatsCardsSocketMessage))) 191 - 192 - fn init_stats_cards_socket( 193 - db: sqlight.Connection, 194 - _connection: mist.WebsocketConnection, 195 - ) -> StatsCardsSocketInit { 196 - let component = stats_cards.component(db) 197 - let assert Ok(runtime) = lustre.start_server_component(component, Nil) 198 - 199 - let self = process.new_subject() 200 - let selector = process.new_selector() |> process.select(self) 201 - 202 - server_component.register_subject(self) 203 - |> lustre.send(to: runtime) 204 - 205 - #(StatsCardsSocket(component: runtime, self: self), option.Some(selector)) 206 - } 207 - 208 - fn loop_stats_cards_socket( 209 - state: StatsCardsSocket, 210 - message: mist.WebsocketMessage(StatsCardsSocketMessage), 211 - connection: mist.WebsocketConnection, 212 - ) -> mist.Next(StatsCardsSocket, StatsCardsSocketMessage) { 213 - case message { 214 - mist.Text(json_string) -> { 215 - case json.parse(json_string, server_component.runtime_message_decoder()) { 216 - Ok(runtime_message) -> lustre.send(state.component, runtime_message) 217 - Error(_) -> Nil 218 - } 219 - 220 - mist.continue(state) 221 - } 222 - 223 - mist.Binary(_) -> mist.continue(state) 224 - 225 - mist.Custom(client_message) -> { 226 - let json_obj = server_component.client_message_to_json(client_message) 227 - let assert Ok(_) = 228 - mist.send_text_frame(connection, json.to_string(json_obj)) 229 - 230 - mist.continue(state) 231 - } 232 - 233 - mist.Closed | mist.Shutdown -> mist.stop() 234 - } 235 - } 236 - 237 - fn close_stats_cards_socket(state: StatsCardsSocket) -> Nil { 238 - lustre.shutdown() 239 - |> lustre.send(to: state.component) 240 - } 241 - 242 - // JETSTREAM ACTIVITY LOG COMPONENT 243 - 244 - /// WebSocket handler for jetstream activity log component 245 - pub fn serve_activity_log( 246 - req: request.Request(mist.Connection), 247 - db: sqlight.Connection, 248 - ) -> response.Response(mist.ResponseData) { 249 - mist.websocket( 250 - request: req, 251 - on_init: init_activity_log_socket(db, _), 252 - handler: loop_activity_log_socket, 253 - on_close: close_activity_log_socket, 254 - ) 255 - } 256 - 257 - type ActivityLogSocket { 258 - ActivityLogSocket( 259 - component: lustre.Runtime(jetstream_activity_log.Msg), 260 - self: process.Subject( 261 - server_component.ClientMessage(jetstream_activity_log.Msg), 262 - ), 263 - ) 264 - } 265 - 266 - type ActivityLogSocketMessage = 267 - server_component.ClientMessage(jetstream_activity_log.Msg) 268 - 269 - type ActivityLogSocketInit = 270 - #(ActivityLogSocket, option.Option(process.Selector(ActivityLogSocketMessage))) 271 - 272 - fn init_activity_log_socket( 273 - db: sqlight.Connection, 274 - _connection: mist.WebsocketConnection, 275 - ) -> ActivityLogSocketInit { 276 - let component = jetstream_activity_log.component(db) 277 - let assert Ok(runtime) = lustre.start_server_component(component, Nil) 278 - 279 - let self = process.new_subject() 280 - let selector = process.new_selector() |> process.select(self) 281 - 282 - server_component.register_subject(self) 283 - |> lustre.send(to: runtime) 284 - 285 - #(ActivityLogSocket(component: runtime, self: self), option.Some(selector)) 286 - } 287 - 288 - fn loop_activity_log_socket( 289 - state: ActivityLogSocket, 290 - message: mist.WebsocketMessage(ActivityLogSocketMessage), 291 - connection: mist.WebsocketConnection, 292 - ) -> mist.Next(ActivityLogSocket, ActivityLogSocketMessage) { 293 - case message { 294 - mist.Text(json_string) -> { 295 - case json.parse(json_string, server_component.runtime_message_decoder()) { 296 - Ok(runtime_message) -> lustre.send(state.component, runtime_message) 297 - Error(_) -> Nil 298 - } 299 - 300 - mist.continue(state) 301 - } 302 - 303 - mist.Binary(_) -> mist.continue(state) 304 - 305 - mist.Custom(client_message) -> { 306 - let json_obj = server_component.client_message_to_json(client_message) 307 - let assert Ok(_) = 308 - mist.send_text_frame(connection, json.to_string(json_obj)) 309 - 310 - mist.continue(state) 311 - } 312 - 313 - mist.Closed | mist.Shutdown -> mist.stop() 314 - } 315 - } 316 - 317 - fn close_activity_log_socket(state: ActivityLogSocket) -> Nil { 318 - lustre.shutdown() 319 - |> lustre.send(to: state.component) 320 - } 321 - 322 - // ACTIVITY CHART COMPONENT 323 - 324 - /// WebSocket handler for activity chart component 325 - pub fn serve_activity_chart( 326 - req: request.Request(mist.Connection), 327 - db: sqlight.Connection, 328 - ) -> response.Response(mist.ResponseData) { 329 - mist.websocket( 330 - request: req, 331 - on_init: init_activity_chart_socket(db, _), 332 - handler: loop_activity_chart_socket, 333 - on_close: close_activity_chart_socket, 334 - ) 335 - } 336 - 337 - type ActivityChartSocket { 338 - ActivityChartSocket( 339 - component: lustre.Runtime(activity_chart.Msg), 340 - self: process.Subject(server_component.ClientMessage(activity_chart.Msg)), 341 - ) 342 - } 343 - 344 - type ActivityChartSocketMessage = 345 - server_component.ClientMessage(activity_chart.Msg) 346 - 347 - type ActivityChartSocketInit = 348 - #( 349 - ActivityChartSocket, 350 - option.Option(process.Selector(ActivityChartSocketMessage)), 351 - ) 352 - 353 - fn init_activity_chart_socket( 354 - db: sqlight.Connection, 355 - _connection: mist.WebsocketConnection, 356 - ) -> ActivityChartSocketInit { 357 - let component = activity_chart.component(db) 358 - let assert Ok(runtime) = lustre.start_server_component(component, Nil) 359 - 360 - let self = process.new_subject() 361 - let selector = process.new_selector() |> process.select(self) 362 - 363 - server_component.register_subject(self) 364 - |> lustre.send(to: runtime) 365 - 366 - #(ActivityChartSocket(component: runtime, self: self), option.Some(selector)) 367 - } 368 - 369 - fn loop_activity_chart_socket( 370 - state: ActivityChartSocket, 371 - message: mist.WebsocketMessage(ActivityChartSocketMessage), 372 - connection: mist.WebsocketConnection, 373 - ) -> mist.Next(ActivityChartSocket, ActivityChartSocketMessage) { 374 - case message { 375 - mist.Text(json_string) -> { 376 - case json.parse(json_string, server_component.runtime_message_decoder()) { 377 - Ok(runtime_message) -> lustre.send(state.component, runtime_message) 378 - Error(_) -> Nil 379 - } 380 - 381 - mist.continue(state) 382 - } 383 - 384 - mist.Binary(_) -> mist.continue(state) 385 - 386 - mist.Custom(client_message) -> { 387 - let json_obj = server_component.client_message_to_json(client_message) 388 - let assert Ok(_) = 389 - mist.send_text_frame(connection, json.to_string(json_obj)) 390 - 391 - mist.continue(state) 392 - } 393 - 394 - mist.Closed | mist.Shutdown -> mist.stop() 395 - } 396 - } 397 - 398 - fn close_activity_chart_socket(state: ActivityChartSocket) -> Nil { 399 - lustre.shutdown() 400 - |> lustre.send(to: state.component) 401 - }
+3 -3
server/src/oauth/handlers.gleam
··· 44 44 use formdata <- wisp.require_form(req) 45 45 46 46 // Get login hint from form 47 - let login_hint = case formdata.values { 48 - [#("loginHint", hint), ..] -> hint 49 - _ -> "" 47 + let login_hint = case list.key_find(formdata.values, "login_hint") { 48 + Ok(hint) -> hint 49 + Error(_) -> "" 50 50 } 51 51 52 52 wisp.log_info("OAuth: Authorization requested for: " <> login_hint)
+16 -8
server/src/oauth/session.gleam
··· 1 1 import gleam/bit_array 2 2 import gleam/crypto 3 3 import gleam/dynamic/decode 4 + import gleam/http/cookie 5 + import gleam/http/response 4 6 import gleam/int 5 7 import gleam/option.{type Option} 6 8 import gleam/result ··· 210 212 Ok(Nil) 211 213 } 212 214 213 - /// Set session cookie on response 215 + /// Set session cookie on response with SameSite=None for fetch with credentials 214 216 pub fn set_session_cookie( 215 217 response: Response, 216 218 req: Request, 217 219 session_id: String, 218 220 ) -> Response { 219 - wisp.set_cookie( 220 - response, 221 - req, 222 - session_cookie_name, 223 - session_id, 224 - wisp.Signed, 225 - 60 * 60 * 24 * 14, 221 + // Sign the session ID the same way wisp does 222 + let signed_value = wisp.sign_message(req, <<session_id:utf8>>, crypto.Sha512) 223 + 224 + // Create cookie attributes without SameSite restriction 225 + let attributes = cookie.Attributes( 226 + max_age: option.Some(60 * 60 * 24 * 14), 227 + domain: option.None, 228 + path: option.Some("/"), 229 + secure: False, // False for localhost HTTP 230 + http_only: True, 231 + same_site: option.None, // No SameSite restriction for JavaScript fetch 226 232 ) 233 + 234 + response.set_cookie(response, session_cookie_name, signed_value, attributes) 227 235 } 228 236 229 237 /// Get session ID from request cookies
-199
server/src/pages/index.gleam
··· 1 - import components/activity_chart 2 - import components/alert 3 - import components/backfill_button 4 - import components/button 5 - import components/jetstream_activity_log 6 - import components/layout 7 - import components/stats_cards 8 - import database 9 - import gleam/option.{type Option} 10 - import gleam/result 11 - import jetstream_activity 12 - import lustre/attribute 13 - import lustre/element.{type Element} 14 - import lustre/element/html 15 - import lustre/server_component 16 - import sqlight 17 - 18 - /// Page data aggregated from database queries 19 - pub type IndexData { 20 - IndexData( 21 - record_count: Int, 22 - lexicon_count: Int, 23 - actor_count: Int, 24 - record_lexicons: List(database.Lexicon), 25 - activity_chart_data: List(jetstream_activity.ActivityBucket), 26 - jetstream_activity: List(jetstream_activity.ActivityEntry), 27 - ) 28 - } 29 - 30 - /// Main view function that renders the index page 31 - pub fn view( 32 - db: sqlight.Connection, 33 - current_user: Option(#(String, String)), 34 - is_admin: Bool, 35 - domain_authority: Option(String), 36 - backfilling: Bool, 37 - ) -> Element(msg) { 38 - let data = fetch_data(db) 39 - render(data, current_user, is_admin, domain_authority, backfilling) 40 - } 41 - 42 - /// Fetch all data needed for the index page 43 - fn fetch_data(db: sqlight.Connection) -> IndexData { 44 - let record_count = case database.get_record_count(db) { 45 - Ok(count) -> count 46 - Error(_) -> 0 47 - } 48 - 49 - let lexicon_count = case database.get_lexicon_count(db) { 50 - Ok(count) -> count 51 - Error(_) -> 0 52 - } 53 - 54 - let actor_count = case database.get_actor_count(db) { 55 - Ok(count) -> count 56 - Error(_) -> 0 57 - } 58 - 59 - let record_lexicons = case database.get_record_type_lexicons(db) { 60 - Ok(lexicons) -> lexicons 61 - Error(_) -> [] 62 - } 63 - 64 - // Get 1-day activity data for the chart (default view) 65 - let activity_chart_data = jetstream_activity.get_activity_1day(db) 66 - |> result.unwrap([]) 67 - 68 - // Get last 24h of individual activity entries for the log 69 - let jetstream_activity_data = 70 - jetstream_activity.get_recent_activity(db, 168) 71 - |> result.unwrap([]) 72 - 73 - IndexData( 74 - record_count: record_count, 75 - lexicon_count: lexicon_count, 76 - actor_count: actor_count, 77 - record_lexicons: record_lexicons, 78 - activity_chart_data: activity_chart_data, 79 - jetstream_activity: jetstream_activity_data, 80 - ) 81 - } 82 - 83 - /// Render the complete index page 84 - fn render( 85 - data: IndexData, 86 - current_user: Option(#(String, String)), 87 - is_admin: Bool, 88 - domain_authority: Option(String), 89 - backfilling: Bool, 90 - ) -> Element(msg) { 91 - layout.page_with_header( 92 - title: "ATProto Database Stats", 93 - content: [ 94 - render_alerts(domain_authority, data.lexicon_count), 95 - render_action_buttons(current_user, is_admin, backfilling), 96 - // Real-time stats cards server component with initial content 97 - server_component.element( 98 - [attribute.id("stats-cards"), server_component.route("/stats-ws")], 99 - [ 100 - stats_cards.render_stats_grid( 101 - data.record_count, 102 - data.actor_count, 103 - data.lexicon_count, 104 - ), 105 - ], 106 - ), 107 - // Activity chart with time range selector 108 - html.div([attribute.class("mb-8")], [ 109 - server_component.element( 110 - [ 111 - attribute.id("activity-chart"), 112 - server_component.route("/activity-chart-ws"), 113 - ], 114 - [activity_chart.render_static(data.activity_chart_data, activity_chart.OneDay)], 115 - ), 116 - ]), 117 - // JetStream activity log with pre-rendered content 118 - html.div([attribute.class("mb-8")], [ 119 - server_component.element( 120 - [ 121 - attribute.id("activity-log"), 122 - server_component.route("/activity-ws"), 123 - ], 124 - [jetstream_activity_log.render_static(data.jetstream_activity)], 125 - ), 126 - ]), 127 - ], 128 - current_user: current_user, 129 - domain_authority: domain_authority, 130 - ) 131 - } 132 - 133 - /// Render configuration alerts if domain authority is missing or no lexicons loaded 134 - fn render_alerts( 135 - domain_authority: Option(String), 136 - lexicon_count: Int, 137 - ) -> Element(msg) { 138 - let domain_alert = case domain_authority { 139 - option.None -> 140 - alert.alert_with_link( 141 - alert.Warning, 142 - "No domain authority configured.", 143 - "Settings", 144 - "/settings", 145 - ) 146 - option.Some(value) -> 147 - case value { 148 - "" -> 149 - alert.alert_with_link( 150 - alert.Warning, 151 - "No domain authority configured.", 152 - "Settings", 153 - "/settings", 154 - ) 155 - _ -> element.none() 156 - } 157 - } 158 - 159 - let lexicon_alert = case lexicon_count { 160 - 0 -> 161 - alert.alert_with_link( 162 - alert.Info, 163 - "No lexicons loaded.", 164 - "Settings", 165 - "/settings", 166 - ) 167 - _ -> element.none() 168 - } 169 - 170 - html.div([], [domain_alert, lexicon_alert]) 171 - } 172 - 173 - /// Render action buttons for authenticated users 174 - fn render_action_buttons( 175 - current_user: Option(#(String, String)), 176 - is_admin: Bool, 177 - backfilling: Bool, 178 - ) -> Element(msg) { 179 - case current_user { 180 - option.Some(_) -> { 181 - html.div([attribute.class("mb-8 flex gap-3")], [ 182 - button.link(href: "/graphiql", text: "Open GraphiQL"), 183 - case is_admin { 184 - True -> 185 - server_component.element( 186 - [ 187 - attribute.id("backfill-button"), 188 - server_component.route("/backfill-ws"), 189 - ], 190 - [backfill_button.render_button_static(is_admin, backfilling)], 191 - ) 192 - False -> element.none() 193 - }, 194 - ]) 195 - } 196 - option.None -> element.none() 197 - } 198 - } 199 -
-267
server/src/pages/settings.gleam
··· 1 - import components/alert 2 - import components/input 3 - import components/layout 4 - import database 5 - import gleam/option.{type Option} 6 - import lustre/attribute 7 - import lustre/element.{type Element} 8 - import lustre/element/html 9 - import sqlight 10 - 11 - /// Main view function that renders the settings page 12 - pub fn view( 13 - db: sqlight.Connection, 14 - current_user: Option(#(String, String)), 15 - flash_kind: Option(String), 16 - flash_message: Option(String), 17 - ) -> Element(msg) { 18 - let data = fetch_settings(db) 19 - render(data, current_user, flash_kind, flash_message) 20 - } 21 - 22 - /// Settings data 23 - pub type SettingsData { 24 - SettingsData(domain_authority: String, oauth_client_id: Option(String)) 25 - } 26 - 27 - /// Fetch current settings 28 - fn fetch_settings(db: sqlight.Connection) -> SettingsData { 29 - let domain_authority = case database.get_config(db, "domain_authority") { 30 - Ok(authority) -> authority 31 - Error(_) -> "" 32 - } 33 - 34 - let oauth_client_id = case database.get_oauth_credentials(db) { 35 - Ok(option.Some(#(client_id, _secret, _uri))) -> option.Some(client_id) 36 - _ -> option.None 37 - } 38 - 39 - SettingsData( 40 - domain_authority: domain_authority, 41 - oauth_client_id: oauth_client_id, 42 - ) 43 - } 44 - 45 - /// Render the complete settings page 46 - fn render( 47 - data: SettingsData, 48 - current_user: Option(#(String, String)), 49 - flash_kind: Option(String), 50 - flash_message: Option(String), 51 - ) -> Element(msg) { 52 - layout.page_with_header( 53 - title: "Settings - quickslice", 54 - content: [ 55 - html.h1([attribute.class("text-2xl font-semibold text-zinc-300 mb-8")], [ 56 - element.text("Settings"), 57 - ]), 58 - alert.maybe_alert(flash_kind, flash_message), 59 - render_settings_form(data), 60 - ], 61 - current_user: current_user, 62 - domain_authority: option.None, 63 - ) 64 - } 65 - 66 - /// Render the settings form 67 - fn render_settings_form(data: SettingsData) -> Element(msg) { 68 - html.div([attribute.class("max-w-2xl space-y-6")], [ 69 - // Domain Authority Section 70 - html.div([attribute.class("bg-zinc-800/50 rounded p-6")], [ 71 - html.h2([attribute.class("text-xl font-semibold text-zinc-300 mb-4")], [ 72 - element.text("Domain Authority"), 73 - ]), 74 - html.form( 75 - [ 76 - attribute.method("post"), 77 - attribute.action("/settings"), 78 - ], 79 - [ 80 - input.form_text_input( 81 - label: "Domain Authority", 82 - name: "domain_authority", 83 - value: data.domain_authority, 84 - placeholder: "e.g. com.example", 85 - required: True, 86 - ), 87 - html.p([attribute.class("text-sm text-zinc-500 mb-4")], [ 88 - element.text( 89 - "The domain authority is used to determine which collections are considered \"primary\" vs \"external\" when backfilling records. For example, if the authority is \"xyz.statusphere\", then \"xyz.statusphere.status\" is treated as primary and \"app.bsky.actor.profile\" is external.", 90 - ), 91 - ]), 92 - html.div([attribute.class("flex gap-3")], [ 93 - html.button( 94 - [ 95 - attribute.type_("submit"), 96 - attribute.class( 97 - "font-mono px-4 py-2 text-sm text-zinc-300 bg-zinc-800 hover:bg-zinc-700 rounded transition-colors cursor-pointer", 98 - ), 99 - ], 100 - [element.text("Save")], 101 - ), 102 - ]), 103 - ], 104 - ), 105 - ]), 106 - // Lexicons Upload Section 107 - html.div([attribute.class("bg-zinc-800/50 rounded p-6")], [ 108 - html.h2([attribute.class("text-xl font-semibold text-zinc-300 mb-4")], [ 109 - element.text("Lexicons"), 110 - ]), 111 - html.form( 112 - [ 113 - attribute.method("post"), 114 - attribute.action("/settings"), 115 - attribute.attribute("enctype", "multipart/form-data"), 116 - ], 117 - [ 118 - input.form_file_input( 119 - label: "Upload Lexicons (ZIP)", 120 - name: "lexicons_zip", 121 - accept: ".zip", 122 - required: False, 123 - ), 124 - html.p([attribute.class("text-sm text-zinc-500 mb-4")], [ 125 - element.text( 126 - "Upload a ZIP file containing lexicon JSON files. The ZIP file will be extracted and all .json files will be imported into the database. This replaces the need to manually place lexicons in the priv/lexicons directory.", 127 - ), 128 - ]), 129 - html.div([attribute.class("flex gap-3")], [ 130 - html.button( 131 - [ 132 - attribute.type_("submit"), 133 - attribute.class( 134 - "font-mono px-4 py-2 text-sm text-zinc-300 bg-zinc-800 hover:bg-zinc-700 rounded transition-colors cursor-pointer", 135 - ), 136 - ], 137 - [element.text("Upload")], 138 - ), 139 - ]), 140 - ], 141 - ), 142 - ]), 143 - // OAuth Registration Section 144 - html.div([attribute.class("bg-zinc-800/50 rounded p-6")], [ 145 - html.h2([attribute.class("text-xl font-semibold text-zinc-300 mb-4")], [ 146 - element.text("OAuth Configuration"), 147 - ]), 148 - case data.oauth_client_id { 149 - option.Some(client_id) -> { 150 - html.div([attribute.class("space-y-3")], [ 151 - html.div([attribute.class("flex items-center gap-2")], [ 152 - html.div( 153 - [ 154 - attribute.class("w-2 h-2 bg-green-500 rounded-full"), 155 - ], 156 - [], 157 - ), 158 - html.p([attribute.class("text-sm text-zinc-300")], [ 159 - element.text("OAuth client registered"), 160 - ]), 161 - ]), 162 - html.div([attribute.class("bg-zinc-900/50 rounded p-3")], [ 163 - html.p([attribute.class("text-xs text-zinc-500 mb-1")], [ 164 - element.text("Client ID:"), 165 - ]), 166 - html.p([attribute.class("text-sm text-zinc-300 font-mono")], [ 167 - element.text(client_id), 168 - ]), 169 - ]), 170 - html.p([attribute.class("text-sm text-zinc-500")], [ 171 - element.text( 172 - "OAuth client credentials are stored in the database. Use \"Reset Everything\" to clear and trigger re-registration.", 173 - ), 174 - ]), 175 - ]) 176 - } 177 - option.None -> { 178 - html.div([attribute.class("space-y-3")], [ 179 - html.div([attribute.class("flex items-center gap-2")], [ 180 - html.div( 181 - [ 182 - attribute.class("w-2 h-2 bg-zinc-500 rounded-full"), 183 - ], 184 - [], 185 - ), 186 - html.p([attribute.class("text-sm text-zinc-400")], [ 187 - element.text("OAuth client not registered"), 188 - ]), 189 - ]), 190 - html.p([attribute.class("text-sm text-zinc-500")], [ 191 - element.text( 192 - "Set ENABLE_OAUTH_AUTO_REGISTER=true in your .env file to enable automatic OAuth client registration. The server will automatically register with your configured AIP server on startup.", 193 - ), 194 - ]), 195 - ]) 196 - } 197 - }, 198 - ]), 199 - // Danger Zone Section 200 - html.div([attribute.class("bg-zinc-800/50 rounded p-6")], [ 201 - html.h2([attribute.class("text-xl font-semibold text-zinc-300 mb-4")], [ 202 - element.text("Danger Zone"), 203 - ]), 204 - html.p([attribute.class("text-sm text-zinc-400 mb-4")], [ 205 - element.text("This will clear all indexed data:"), 206 - ]), 207 - html.ul([attribute.class("text-sm text-zinc-400 mb-4 ml-4 list-disc")], [ 208 - html.li([], [element.text("Domain authority configuration")]), 209 - html.li([], [element.text("OAuth client credentials")]), 210 - html.li([], [element.text("All lexicon definitions")]), 211 - html.li([], [element.text("All indexed records")]), 212 - html.li([], [element.text("All actors")]), 213 - ]), 214 - html.p([attribute.class("text-sm text-zinc-400 mb-4")], [ 215 - element.text("Records can be re-indexed via backfill."), 216 - ]), 217 - html.form( 218 - [ 219 - attribute.method("post"), 220 - attribute.action("/settings"), 221 - ], 222 - [ 223 - html.input([ 224 - attribute.type_("hidden"), 225 - attribute.name("action"), 226 - attribute.value("reset"), 227 - ]), 228 - input.form_text_input( 229 - label: "Type RESET to confirm", 230 - name: "confirm", 231 - value: "", 232 - placeholder: "RESET", 233 - required: True, 234 - ), 235 - html.div([attribute.class("flex gap-3")], [ 236 - html.button( 237 - [ 238 - attribute.type_("submit"), 239 - attribute.class( 240 - "font-mono px-4 py-2 text-sm text-red-400 border border-red-900 hover:bg-red-900/30 rounded transition-colors cursor-pointer", 241 - ), 242 - ], 243 - [element.text("Reset Everything")], 244 - ), 245 - ]), 246 - ], 247 - ), 248 - ]), 249 - // Account Section 250 - html.div([attribute.class("bg-zinc-800/50 rounded p-6")], [ 251 - html.h2([attribute.class("text-xl font-semibold text-zinc-300 mb-4")], [ 252 - element.text("Account"), 253 - ]), 254 - html.form([attribute.method("post"), attribute.action("/logout")], [ 255 - html.button( 256 - [ 257 - attribute.type_("submit"), 258 - attribute.class( 259 - "font-mono px-4 py-2 text-sm text-zinc-400 border border-zinc-700 hover:border-zinc-600 hover:text-zinc-300 rounded transition-colors cursor-pointer", 260 - ), 261 - ], 262 - [element.text("Sign Out")], 263 - ), 264 - ]), 265 - ]), 266 - ]) 267 - }
-213
server/src/pages/upload.gleam
··· 1 - import components/layout 2 - import lustre/attribute 3 - import lustre/element.{type Element} 4 - import lustre/element/html 5 - 6 - /// Render the upload blob page 7 - pub fn view(handle: String, oauth_token: String) -> Element(msg) { 8 - layout.page(title: "quickslice - Upload Blob", content: [ 9 - render_header(handle), 10 - render_upload_form(oauth_token), 11 - ]) 12 - } 13 - 14 - /// Render the page header with user info 15 - fn render_header(handle: String) -> Element(msg) { 16 - html.div([attribute.class("mb-8 flex justify-between items-center")], [ 17 - html.div([], [ 18 - html.h1([attribute.class("text-4xl font-bold text-zinc-200 mb-2")], [ 19 - element.text("Upload Blob"), 20 - ]), 21 - html.p([attribute.class("text-zinc-400")], [ 22 - element.text("Test the uploadBlob mutation by uploading a file"), 23 - ]), 24 - ]), 25 - html.div([attribute.class("text-right")], [ 26 - html.p([attribute.class("text-sm text-zinc-500")], [ 27 - element.text("Logged in as"), 28 - ]), 29 - html.p([attribute.class("text-lg font-semibold text-zinc-200")], [ 30 - element.text("@" <> handle), 31 - ]), 32 - html.a( 33 - [ 34 - attribute.href("/"), 35 - attribute.class( 36 - "text-sm text-zinc-400 hover:text-zinc-300 transition-colors", 37 - ), 38 - ], 39 - [element.text("← Back to Home")], 40 - ), 41 - ]), 42 - ]) 43 - } 44 - 45 - /// Render the upload form with JavaScript 46 - fn render_upload_form(oauth_token: String) -> Element(msg) { 47 - html.div([attribute.class("bg-zinc-800/50 rounded p-6")], [ 48 - html.form([attribute.id("uploadForm"), attribute.class("space-y-6")], [ 49 - html.input([ 50 - attribute.type_("hidden"), 51 - attribute.id("token"), 52 - attribute.value(oauth_token), 53 - ]), 54 - html.div([], [ 55 - html.label( 56 - [ 57 - attribute.for("file"), 58 - attribute.class("block text-sm font-medium text-zinc-300 mb-2"), 59 - ], 60 - [element.text("File to Upload")], 61 - ), 62 - html.input([ 63 - attribute.type_("file"), 64 - attribute.id("file"), 65 - attribute.name("file"), 66 - attribute.class( 67 - "w-full px-3 py-2 bg-zinc-900 border border-zinc-700 rounded text-zinc-300 focus:outline-none focus:border-zinc-600", 68 - ), 69 - attribute.attribute("required", ""), 70 - ]), 71 - html.p([attribute.class("mt-1 text-sm text-zinc-500")], [ 72 - element.text("Select an image or any file to upload as a blob"), 73 - ]), 74 - ]), 75 - html.div([], [ 76 - html.button( 77 - [ 78 - attribute.type_("submit"), 79 - attribute.class( 80 - "w-full bg-zinc-700 hover:bg-zinc-600 text-zinc-200 font-semibold py-3 px-4 rounded transition-colors", 81 - ), 82 - ], 83 - [element.text("Upload Blob")], 84 - ), 85 - ]), 86 - ]), 87 - html.div([attribute.id("result"), attribute.class("mt-6 hidden")], [ 88 - html.h2([attribute.class("text-lg font-semibold text-zinc-200 mb-2")], [ 89 - element.text("Result:"), 90 - ]), 91 - html.div( 92 - [ 93 - attribute.id("resultContent"), 94 - attribute.class( 95 - "bg-zinc-900 rounded p-4 border border-zinc-700 text-zinc-300 font-mono text-sm overflow-auto max-h-96", 96 - ), 97 - ], 98 - [], 99 - ), 100 - ]), 101 - html.div([attribute.id("error"), attribute.class("mt-6 hidden")], [ 102 - html.h2([attribute.class("text-lg font-semibold text-red-400 mb-2")], [ 103 - element.text("Error:"), 104 - ]), 105 - html.div( 106 - [ 107 - attribute.id("errorContent"), 108 - attribute.class( 109 - "bg-red-950 rounded p-4 border border-red-900 text-red-300 font-mono text-sm overflow-auto max-h-96", 110 - ), 111 - ], 112 - [], 113 - ), 114 - ]), 115 - render_upload_script(), 116 - ]) 117 - } 118 - 119 - /// Render the JavaScript for handling file upload 120 - fn render_upload_script() -> Element(msg) { 121 - let script_content = 122 - " 123 - const form = document.getElementById('uploadForm'); 124 - const fileInput = document.getElementById('file'); 125 - const tokenInput = document.getElementById('token'); 126 - const resultDiv = document.getElementById('result'); 127 - const resultContent = document.getElementById('resultContent'); 128 - const errorDiv = document.getElementById('error'); 129 - const errorContent = document.getElementById('errorContent'); 130 - 131 - form.addEventListener('submit', async (e) => { 132 - e.preventDefault(); 133 - 134 - // Hide previous results 135 - resultDiv.classList.add('hidden'); 136 - errorDiv.classList.add('hidden'); 137 - 138 - const file = fileInput.files[0]; 139 - const token = tokenInput.value; 140 - 141 - if (!file || !token) { 142 - showError('Please select a file and provide a token'); 143 - return; 144 - } 145 - 146 - try { 147 - // Read file as ArrayBuffer and convert to base64 148 - const arrayBuffer = await file.arrayBuffer(); 149 - const bytes = new Uint8Array(arrayBuffer); 150 - let binary = ''; 151 - for (let i = 0; i < bytes.byteLength; i++) { 152 - binary += String.fromCharCode(bytes[i]); 153 - } 154 - const base64Data = btoa(binary); 155 - 156 - // Call our GraphQL uploadBlob mutation 157 - const mutation = ` 158 - mutation UploadBlob($data: String!, $mimeType: String!) { 159 - uploadBlob(data: $data, mimeType: $mimeType) { 160 - ref 161 - mimeType 162 - size 163 - } 164 - } 165 - `; 166 - 167 - const graphqlResponse = await fetch('/graphql', { 168 - method: 'POST', 169 - headers: { 170 - 'Content-Type': 'application/json', 171 - 'Authorization': `Bearer ${token}` 172 - }, 173 - body: JSON.stringify({ 174 - query: mutation, 175 - variables: { 176 - data: base64Data, 177 - mimeType: file.type || 'application/octet-stream' 178 - } 179 - }) 180 - }); 181 - 182 - const graphqlData = await graphqlResponse.json(); 183 - 184 - if (graphqlData.errors && graphqlData.errors.length > 0) { 185 - throw new Error(JSON.stringify(graphqlData.errors, null, 2)); 186 - } 187 - 188 - showResult({ 189 - file: { 190 - name: file.name, 191 - size: file.size, 192 - type: file.type 193 - }, 194 - graphqlResponse: graphqlData 195 - }); 196 - } catch (error) { 197 - showError(error.message); 198 - } 199 - }); 200 - 201 - function showResult(data) { 202 - resultContent.textContent = JSON.stringify(data, null, 2); 203 - resultDiv.classList.remove('hidden'); 204 - } 205 - 206 - function showError(message) { 207 - errorContent.textContent = message; 208 - errorDiv.classList.remove('hidden'); 209 - } 210 - " 211 - 212 - html.script([], script_content) 213 - }
+78 -130
server/src/server.gleam
··· 1 1 import activity_cleanup 2 2 import argv 3 3 import backfill 4 + import client_graphql_handler 4 5 import backfill_state 5 6 import config 6 7 import database ··· 12 13 import gleam/int 13 14 import gleam/list 14 15 import gleam/option 15 - import gleam/otp/actor 16 16 import gleam/string 17 17 import graphiql_handler 18 18 import graphql_handler ··· 20 20 import importer 21 21 import jetstream_consumer 22 22 import logging 23 - import lustre/element 24 - import lustre_handlers 25 23 import mist 26 24 import oauth/handlers 27 25 import oauth/registration 28 - import oauth/session 29 - import pages/index 30 26 import pubsub 31 - import settings_handler 27 + import simplifile 32 28 import sqlight 33 29 import stats_pubsub 34 30 import upload_handler ··· 311 307 // Get HOST and PORT from environment variables or use defaults 312 308 let host = case envoy.get("HOST") { 313 309 Ok(h) -> h 314 - Error(_) -> "127.0.0.1" 310 + Error(_) -> "localhost" 315 311 } 316 312 317 313 let port = case envoy.get("PORT") { ··· 486 482 // Create Wisp handler converted to Mist format 487 483 let wisp_handler = wisp_mist.handler(handler, secret_key_base) 488 484 489 - // Wrap it to intercept WebSocket upgrades and serve Lustre runtime 485 + // Wrap it to intercept WebSocket upgrades for GraphQL subscriptions 490 486 let mist_handler = fn(req: request.Request(mist.Connection)) { 491 487 let upgrade_header = request.get_header(req, "upgrade") 492 488 let path = request.path_segments(req) 493 489 494 490 case path { 495 - // Serve Lustre client runtime 496 - ["lustre", "runtime.mjs"] -> lustre_handlers.serve_lustre_runtime() 497 - 498 - // Serve styles CSS 499 - ["styles.css"] -> lustre_handlers.serve_tailwind_css() 500 - 501 - // Backfill button WebSocket 502 - ["backfill-ws"] -> { 503 - case upgrade_header { 504 - Ok(upgrade_value) -> { 505 - case string.lowercase(upgrade_value) { 506 - "websocket" -> { 507 - lustre_handlers.serve_backfill_button( 508 - req, 509 - ctx.db, 510 - ctx.backfill_state, 511 - ctx.config, 512 - ) 513 - } 514 - _ -> wisp_handler(req) 515 - } 516 - } 517 - _ -> wisp_handler(req) 518 - } 519 - } 520 - 521 - // Stats cards WebSocket 522 - ["stats-ws"] -> { 523 - case upgrade_header { 524 - Ok(upgrade_value) -> { 525 - case string.lowercase(upgrade_value) { 526 - "websocket" -> { 527 - lustre_handlers.serve_stats_cards(req, ctx.db) 528 - } 529 - _ -> wisp_handler(req) 530 - } 531 - } 532 - _ -> wisp_handler(req) 533 - } 534 - } 535 - 536 - // Activity log WebSocket 537 - ["activity-ws"] -> { 538 - case upgrade_header { 539 - Ok(upgrade_value) -> { 540 - case string.lowercase(upgrade_value) { 541 - "websocket" -> { 542 - lustre_handlers.serve_activity_log(req, ctx.db) 543 - } 544 - _ -> wisp_handler(req) 545 - } 546 - } 547 - _ -> wisp_handler(req) 548 - } 549 - } 550 - 551 - // Activity chart WebSocket 552 - ["activity-chart-ws"] -> { 553 - case upgrade_header { 554 - Ok(upgrade_value) -> { 555 - case string.lowercase(upgrade_value) { 556 - "websocket" -> { 557 - lustre_handlers.serve_activity_chart(req, ctx.db) 558 - } 559 - _ -> wisp_handler(req) 560 - } 561 - } 562 - _ -> wisp_handler(req) 563 - } 564 - } 565 - 566 - // GraphQL WebSocket 491 + // GraphQL WebSocket for subscriptions 567 492 ["graphql"] | ["", "graphql"] -> { 568 493 case upgrade_header { 569 494 Ok(upgrade_value) -> { ··· 607 532 process.sleep_forever() 608 533 } 609 534 610 - /// Check if a DID has admin access based on the ADMIN_DIDS list 611 - fn is_admin(did: String, admin_dids: List(String)) -> Bool { 612 - list.contains(admin_dids, did) 613 - } 614 - 615 535 fn handle_request(req: wisp.Request, ctx: Context) -> wisp.Response { 616 536 use _req <- middleware(req) 617 537 ··· 620 540 case segments { 621 541 [] -> index_route(req, ctx) 622 542 ["health"] -> handle_health_check(ctx) 623 - ["settings"] -> 624 - settings_handler.handle( 625 - req, 626 - settings_handler.Context( 627 - db: ctx.db, 628 - oauth_config: ctx.oauth_config, 629 - admin_dids: ctx.admin_dids, 630 - config: ctx.config, 631 - jetstream_consumer: ctx.jetstream_consumer, 632 - ), 633 - ) 543 + // Serve client static files (bundled by lustre dev tools) 544 + ["quickslice_client.js"] -> serve_static_file(["quickslice_client.js"]) 545 + ["styles.css"] -> serve_static_file(["styles.css"]) 634 546 ["oauth", "authorize"] -> 635 547 handlers.handle_oauth_authorize(req, ctx.db, ctx.oauth_config) 636 548 ["oauth", "callback"] -> 637 549 handlers.handle_oauth_callback(req, ctx.db, ctx.oauth_config) 638 550 ["logout"] -> handlers.handle_logout(req, ctx.db) 639 551 ["backfill"] -> handle_backfill_request(req, ctx) 552 + ["admin", "graphql"] -> 553 + client_graphql_handler.handle_client_graphql_request(req, ctx.db, ctx.admin_dids, ctx.jetstream_consumer) 640 554 ["graphql"] -> 641 555 graphql_handler.handle_graphql_request( 642 556 req, ··· 703 617 } 704 618 } 705 619 } 706 - _ -> wisp.html_response("<h1>Not Found</h1>", 404) 620 + // Fallback: serve SPA index.html for client-side routing 621 + _ -> index_route(req, ctx) 707 622 } 708 623 } 709 624 ··· 805 720 } 806 721 } 807 722 808 - fn index_route(req: wisp.Request, ctx: Context) -> wisp.Response { 809 - // Get current user from session (with automatic token refresh) 810 - let refresh_fn = fn(refresh_token) { 811 - handlers.refresh_access_token(ctx.oauth_config, refresh_token) 723 + fn index_route(_req: wisp.Request, _ctx: Context) -> wisp.Response { 724 + // Serve the client SPA's index.html (bundled by lustre dev tools) 725 + case simplifile.read("priv/static/index.html") { 726 + Ok(contents) -> wisp.html_response(contents, 200) 727 + Error(_) -> 728 + wisp.html_response( 729 + "<h1>Error</h1><p>Client application not found. Run 'gleam run -m lustre/dev build' in the client directory.</p>", 730 + 500, 731 + ) 812 732 } 733 + } 813 734 814 - let #(current_user, user_is_admin) = case 815 - session.get_current_user(req, ctx.db, refresh_fn) 816 - { 817 - Ok(#(did, handle, _access_token)) -> { 818 - let admin = is_admin(did, ctx.admin_dids) 819 - #(option.Some(#(did, handle)), admin) 820 - } 821 - Error(_) -> #(option.None, False) 822 - } 735 + fn serve_static_file(path_segments: List(String)) -> wisp.Response { 736 + let file_path = "priv/static/" <> string.join(path_segments, "/") 823 737 824 - // Get domain authority from config cache 825 - let domain_authority = config.get_domain_authority(ctx.config) 826 - 827 - // Get backfill state 828 - let backfilling = 829 - actor.call( 830 - ctx.backfill_state, 831 - waiting: 100, 832 - sending: backfill_state.IsBackfilling, 833 - ) 738 + case simplifile.read(file_path) { 739 + Ok(contents) -> { 740 + // Determine content type based on file extension 741 + let content_type = case list.last(path_segments) { 742 + Ok(filename) -> { 743 + case string.ends_with(filename, ".mjs") || string.ends_with( 744 + filename, 745 + ".js", 746 + ) { 747 + True -> "application/javascript" 748 + False -> 749 + case string.ends_with(filename, ".css") { 750 + True -> "text/css" 751 + False -> 752 + case string.ends_with(filename, ".html") { 753 + True -> "text/html" 754 + False -> "application/octet-stream" 755 + } 756 + } 757 + } 758 + } 759 + Error(_) -> "application/octet-stream" 760 + } 834 761 835 - index.view( 836 - ctx.db, 837 - current_user, 838 - user_is_admin, 839 - domain_authority, 840 - backfilling, 841 - ) 842 - |> element.to_document_string 843 - |> wisp.html_response(200) 762 + wisp.response(200) 763 + |> wisp.set_header("content-type", content_type) 764 + |> wisp.set_body(wisp.Text(contents)) 765 + } 766 + Error(_) -> wisp.response(404) |> wisp.set_body(wisp.Text("Not found")) 767 + } 844 768 } 845 769 846 770 fn middleware( ··· 851 775 use <- wisp.log_request(req) 852 776 use req <- wisp.handle_head(req) 853 777 854 - handle_request(req) 778 + // Get origin from request headers 779 + let origin = case request.get_header(req, "origin") { 780 + Ok(o) -> o 781 + Error(_) -> "http://localhost:8000" 782 + } 783 + 784 + // Handle CORS preflight requests 785 + case req.method { 786 + gleam_http.Options -> { 787 + wisp.response(200) 788 + |> wisp.set_header("access-control-allow-origin", origin) 789 + |> wisp.set_header("access-control-allow-credentials", "true") 790 + |> wisp.set_header("access-control-allow-methods", "GET, POST, OPTIONS") 791 + |> wisp.set_header("access-control-allow-headers", "Content-Type") 792 + |> wisp.set_body(wisp.Text("")) 793 + } 794 + _ -> { 795 + // Add CORS headers to all responses 796 + handle_request(req) 797 + |> wisp.set_header("access-control-allow-origin", origin) 798 + |> wisp.set_header("access-control-allow-credentials", "true") 799 + |> wisp.set_header("access-control-allow-methods", "GET, POST, OPTIONS") 800 + |> wisp.set_header("access-control-allow-headers", "Content-Type") 801 + } 802 + } 855 803 }
+6 -9
server/src/settings_handler.gleam
··· 9 9 import importer 10 10 import jetstream_consumer 11 11 import logging 12 - import lustre/element 13 12 import oauth/handlers 14 13 import oauth/session 15 - import pages/settings 16 14 import simplifile 17 15 import sqlight 18 16 import wisp ··· 63 61 fn handle_admin_request( 64 62 req: wisp.Request, 65 63 ctx: Context, 66 - current_user: option.Option(#(String, String)), 64 + _current_user: option.Option(#(String, String)), 67 65 ) -> wisp.Response { 68 66 case req.method { 69 67 gleam_http.Get -> { 70 - // Extract flash messages if present 71 - use flash_kind, flash_message <- wisp_flash.get_flash(req) 72 - 73 - settings.view(ctx.db, current_user, flash_kind, flash_message) 74 - |> element.to_document_string 75 - |> wisp.html_response(200) 68 + // TODO: Migrate settings page to client SPA 69 + wisp.html_response( 70 + "<h1>Settings</h1><p>Settings page will be migrated to the client SPA</p>", 71 + 200, 72 + ) 76 73 } 77 74 gleam_http.Post -> { 78 75 // Handle form submission (domain authority or lexicons upload or reset)
+8 -6
server/src/upload_handler.gleam
··· 2 2 /// 3 3 /// Serves a simple form to upload blobs and test the uploadBlob mutation 4 4 import gleam/http 5 - import lustre/element 6 5 import oauth/handlers 7 6 import oauth/session 8 - import pages/upload 9 7 import sqlight 10 8 import wisp 11 9 ··· 36 34 // User is not logged in - redirect to home with error 37 35 wisp.redirect("/?error=Please+log+in+to+upload+blobs") 38 36 } 39 - Ok(#(_did, handle, access_token)) -> { 40 - upload.view(handle, access_token) 41 - |> element.to_document_string 42 - |> wisp.html_response(200) 37 + Ok(#(_did, handle, _access_token)) -> { 38 + // TODO: Migrate upload page to client SPA 39 + wisp.html_response( 40 + "<h1>Upload</h1><p>Upload page will be migrated to the client SPA. Logged in as @" 41 + <> handle 42 + <> "</p>", 43 + 200, 44 + ) 43 45 } 44 46 } 45 47 }