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.

add tailwind, use real-time server components for stats cards

+457 -58
+9 -2
Makefile
··· 1 - .PHONY: help test build clean run 1 + .PHONY: help test build clean run css 2 2 3 3 help: 4 4 @echo "quickslice - Makefile Commands" ··· 6 6 @echo " make run - Start server" 7 7 @echo " make test - Run all tests" 8 8 @echo " make build - Build all projects" 9 + @echo " make css - Build Tailwind CSS" 9 10 @echo " make clean - Clean build artifacts" 10 11 @echo "" 11 12 13 + # Build Tailwind CSS 14 + css: 15 + @echo "Building Tailwind CSS..." 16 + @cd server && npx @tailwindcss/cli -i src/app.css -o priv/static/styles.css --minify 17 + @echo "CSS build complete" 18 + 12 19 # Build all projects 13 - build: 20 + build: css 14 21 @echo "Building lexicon_graphql package..." 15 22 @cd lexicon_graphql && gleam build 16 23 @echo ""
+2
server/priv/static/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-divide-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-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-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-800:oklch(42.4% .199 265.638);--color-blue-900:oklch(37.9% .146 265.522);--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);--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-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-96{max-height:calc(var(--spacing)*96)}.min-h-screen{min-height:100vh}.w-2{width:calc(var(--spacing)*2)}.w-10{width:calc(var(--spacing)*10)}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-4xl{max-width:var(--container-4xl)}.min-w-full{min-width:100%}.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}.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-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)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px*var(--tw-divide-y-reverse));border-bottom-width:calc(1px*calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-zinc-800>:not(:last-child)){border-color:var(--color-zinc-800)}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.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-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.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-800{border-color:var(--color-zinc-800)}.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-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-12{padding-block:calc(var(--spacing)*12)}.pb-4{padding-bottom:calc(var(--spacing)*4)}.text-left{text-align:left}.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))}.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)}.text-blue-300{color:var(--color-blue-300)}.text-green-300{color:var(--color-green-300)}.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-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}.italic{font-style:italic}.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-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}@media (hover:hover){.hover\: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-800:hover{background-color:var(--color-zinc-800)}.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-divide-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";
+33 -5
server/src/components/backfill_button.gleam
··· 182 182 183 183 // VIEW 184 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 + 185 202 fn view(model: Model) -> Element(Msg) { 186 203 case model.is_admin { 187 204 False -> element.none() ··· 191 208 False -> "Backfill Collections" 192 209 } 193 210 194 - html.div([attribute.class("inline")], [ 195 - button.button( 196 - disabled: model.backfilling, 197 - on_click: UserClickedBackfill, 198 - text: button_text, 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 + [], 199 220 ), 221 + html.div([attribute.class("inline")], [ 222 + button.button( 223 + disabled: model.backfilling, 224 + on_click: UserClickedBackfill, 225 + text: button_text, 226 + ), 227 + ]), 200 228 ]) 201 229 } 202 230 }
+12
server/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( 29 + [ 30 + attribute.type_("button"), 31 + attribute.class(button_classes), 32 + attribute.disabled(disabled), 33 + ], 34 + [html.text(text)], 35 + ) 36 + } 37 + 26 38 /// Render a link styled as a button 27 39 pub fn link(href href: String, text text: String) -> Element(msg) { 28 40 html.a([attribute.href(href), attribute.class(button_classes)], [
+5 -2
server/src/components/layout.gleam
··· 50 50 [], 51 51 ), 52 52 element.element( 53 - "script", 54 - [attribute.attribute("src", "https://cdn.tailwindcss.com")], 53 + "link", 54 + [ 55 + attribute.attribute("rel", "stylesheet"), 56 + attribute.attribute("href", "/styles.css"), 57 + ], 55 58 [], 56 59 ), 57 60 // Lustre server component runtime
+160
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 + } 76 + } 77 + } 78 + } 79 + 80 + // EFFECTS 81 + 82 + fn start_listening_in_background() -> effect.Effect(Msg) { 83 + use dispatch <- effect.from 84 + 85 + // Spawn a single long-running process to listen for stats events 86 + let _ = 87 + process.spawn_unlinked(fn() { 88 + // Subscribe in THIS process, not the component process 89 + let subscriber = stats_pubsub.subscribe() 90 + listen_loop(subscriber, dispatch) 91 + }) 92 + 93 + Nil 94 + } 95 + 96 + fn listen_loop( 97 + subscriber: process.Subject(stats_pubsub.StatsEvent), 98 + dispatch: fn(Msg) -> Nil, 99 + ) -> Nil { 100 + let selector = process.new_selector() |> process.select(subscriber) 101 + 102 + let event = process.selector_receive_forever(selector) 103 + dispatch(StatsEventReceived(event)) 104 + // Keep listening 105 + listen_loop(subscriber, dispatch) 106 + } 107 + 108 + // VIEW 109 + 110 + /// Renders the stats cards grid with the given counts 111 + pub fn render_stats_grid( 112 + record_count: Int, 113 + actor_count: Int, 114 + lexicon_count: Int, 115 + ) -> Element(msg) { 116 + html.div([attribute.class("mb-8 grid grid-cols-3 gap-4")], [ 117 + // Total records stat card 118 + html.div([attribute.class("bg-zinc-800/50 rounded p-4")], [ 119 + html.div([attribute.class("text-sm text-zinc-500 mb-1")], [ 120 + element.text("Total Records"), 121 + ]), 122 + html.div([attribute.class("text-2xl font-semibold text-zinc-200")], [ 123 + element.text(format.format_number(record_count)), 124 + ]), 125 + ]), 126 + // Actors stat card 127 + html.div([attribute.class("bg-zinc-800/50 rounded p-4")], [ 128 + html.div([attribute.class("text-sm text-zinc-500 mb-1")], [ 129 + element.text("Total Actors"), 130 + ]), 131 + html.div([attribute.class("text-2xl font-semibold text-zinc-200")], [ 132 + element.text(format.format_number(actor_count)), 133 + ]), 134 + ]), 135 + // Lexicons stat card (static for now) 136 + html.div([attribute.class("bg-zinc-800/50 rounded p-4")], [ 137 + html.div([attribute.class("text-sm text-zinc-500 mb-1")], [ 138 + element.text("Total Lexicons"), 139 + ]), 140 + html.div([attribute.class("text-2xl font-semibold text-zinc-200")], [ 141 + element.text(format.format_number(lexicon_count)), 142 + ]), 143 + ]), 144 + ]) 145 + } 146 + 147 + fn view(model: Model) -> Element(Msg) { 148 + html.div([attribute.class("font-mono")], [ 149 + // Include Tailwind styles in the Shadow DOM 150 + element.element( 151 + "link", 152 + [ 153 + attribute.attribute("rel", "stylesheet"), 154 + attribute.attribute("href", "/styles.css"), 155 + ], 156 + [], 157 + ), 158 + render_stats_grid(model.record_count, model.actor_count, model.lexicon_count), 159 + ]) 160 + }
+13
server/src/event_handler.gleam
··· 11 11 import logging 12 12 import pubsub 13 13 import sqlight 14 + import stats_pubsub 14 15 15 16 /// Convert a Dynamic value (Erlang term) to JSON string 16 17 fn dynamic_to_json(value: Dynamic) -> String { ··· 106 107 // We're already in a spawned process per event, so blocking is fine 107 108 case is_new_actor { 108 109 True -> { 110 + // Publish stats event for new actor 111 + stats_pubsub.publish(stats_pubsub.ActorCreated) 112 + 109 113 backfill.backfill_external_collections_for_actor( 110 114 db, 111 115 did, ··· 165 169 ) 166 170 167 171 pubsub.publish(event) 172 + 173 + // Publish stats event for real-time stats updates 174 + case is_create { 175 + True -> stats_pubsub.publish(stats_pubsub.RecordCreated) 176 + False -> Nil 177 + } 168 178 } 169 179 Error(err) -> { 170 180 logging.log( ··· 248 258 ) 249 259 250 260 pubsub.publish(event) 261 + 262 + // Publish stats event for real-time stats updates 263 + stats_pubsub.publish(stats_pubsub.RecordDeleted) 251 264 } 252 265 Error(err) -> { 253 266 logging.log(
+96
server/src/lustre_handlers.gleam
··· 5 5 /// managing component WebSocket connections. 6 6 import backfill_state 7 7 import components/backfill_button 8 + import components/stats_cards 8 9 import config 9 10 import gleam/bytes_tree 10 11 import gleam/erlang/application ··· 30 31 Ok(file) -> 31 32 response.new(200) 32 33 |> response.prepend_header("content-type", "application/javascript") 34 + |> response.set_body(file) 35 + 36 + Error(_) -> 37 + response.new(404) 38 + |> response.set_body(mist.Bytes(bytes_tree.new())) 39 + } 40 + } 41 + 42 + /// Serve the Tailwind CSS file 43 + pub fn serve_tailwind_css() -> response.Response(mist.ResponseData) { 44 + let assert Ok(server_priv) = application.priv_directory("server") 45 + let file_path = server_priv <> "/static/styles.css" 46 + 47 + case mist.send_file(file_path, offset: 0, limit: option.None) { 48 + Ok(file) -> 49 + response.new(200) 50 + |> response.prepend_header("content-type", "text/css") 33 51 |> response.set_body(file) 34 52 35 53 Error(_) -> ··· 140 158 lustre.shutdown() 141 159 |> lustre.send(to: state.component) 142 160 } 161 + 162 + // STATS CARDS COMPONENT 163 + 164 + /// WebSocket handler for stats cards component 165 + pub fn serve_stats_cards( 166 + req: request.Request(mist.Connection), 167 + db: sqlight.Connection, 168 + ) -> response.Response(mist.ResponseData) { 169 + mist.websocket( 170 + request: req, 171 + on_init: init_stats_cards_socket(db, _), 172 + handler: loop_stats_cards_socket, 173 + on_close: close_stats_cards_socket, 174 + ) 175 + } 176 + 177 + type StatsCardsSocket { 178 + StatsCardsSocket( 179 + component: lustre.Runtime(stats_cards.Msg), 180 + self: process.Subject(server_component.ClientMessage(stats_cards.Msg)), 181 + ) 182 + } 183 + 184 + type StatsCardsSocketMessage = 185 + server_component.ClientMessage(stats_cards.Msg) 186 + 187 + type StatsCardsSocketInit = 188 + #(StatsCardsSocket, option.Option(process.Selector(StatsCardsSocketMessage))) 189 + 190 + fn init_stats_cards_socket( 191 + db: sqlight.Connection, 192 + _connection: mist.WebsocketConnection, 193 + ) -> StatsCardsSocketInit { 194 + let component = stats_cards.component(db) 195 + let assert Ok(runtime) = lustre.start_server_component(component, Nil) 196 + 197 + let self = process.new_subject() 198 + let selector = process.new_selector() |> process.select(self) 199 + 200 + server_component.register_subject(self) 201 + |> lustre.send(to: runtime) 202 + 203 + #(StatsCardsSocket(component: runtime, self: self), option.Some(selector)) 204 + } 205 + 206 + fn loop_stats_cards_socket( 207 + state: StatsCardsSocket, 208 + message: mist.WebsocketMessage(StatsCardsSocketMessage), 209 + connection: mist.WebsocketConnection, 210 + ) -> mist.Next(StatsCardsSocket, StatsCardsSocketMessage) { 211 + case message { 212 + mist.Text(json_string) -> { 213 + case json.parse(json_string, server_component.runtime_message_decoder()) { 214 + Ok(runtime_message) -> lustre.send(state.component, runtime_message) 215 + Error(_) -> Nil 216 + } 217 + 218 + mist.continue(state) 219 + } 220 + 221 + mist.Binary(_) -> mist.continue(state) 222 + 223 + mist.Custom(client_message) -> { 224 + let json_obj = server_component.client_message_to_json(client_message) 225 + let assert Ok(_) = 226 + mist.send_text_frame(connection, json.to_string(json_obj)) 227 + 228 + mist.continue(state) 229 + } 230 + 231 + mist.Closed | mist.Shutdown -> mist.stop() 232 + } 233 + } 234 + 235 + fn close_stats_cards_socket(state: StatsCardsSocket) -> Nil { 236 + lustre.shutdown() 237 + |> lustre.send(to: state.component) 238 + }
+18 -44
server/src/pages/index.gleam
··· 1 1 import components/alert 2 + import components/backfill_button 2 3 import components/button 3 4 import components/collection_table 4 5 import components/layout 5 6 import components/sparkline 7 + import components/stats_cards 6 8 import database 7 - import format 8 9 import gleam/option.{type Option} 9 10 import lustre/attribute 10 11 import lustre/element.{type Element} ··· 30 31 current_user: Option(#(String, String)), 31 32 is_admin: Bool, 32 33 domain_authority: Option(String), 34 + backfilling: Bool, 33 35 ) -> Element(msg) { 34 36 let data = fetch_data(db) 35 - render(data, current_user, is_admin, domain_authority) 37 + render(data, current_user, is_admin, domain_authority, backfilling) 36 38 } 37 39 38 40 /// Fetch all data needed for the index page ··· 83 85 current_user: Option(#(String, String)), 84 86 is_admin: Bool, 85 87 domain_authority: Option(String), 88 + backfilling: Bool, 86 89 ) -> Element(msg) { 87 90 layout.page_with_header( 88 91 title: "ATProto Database Stats", 89 92 content: [ 90 93 render_alerts(domain_authority, data.lexicon_count), 91 94 render_action_buttons(current_user), 92 - render_stats_section( 93 - data.record_count, 94 - data.lexicon_count, 95 - data.actor_count, 95 + // Real-time stats cards server component with initial content 96 + server_component.element( 97 + [attribute.id("stats-cards"), server_component.route("/stats-ws")], 98 + [ 99 + stats_cards.render_stats_grid( 100 + data.record_count, 101 + data.actor_count, 102 + data.lexicon_count, 103 + ), 104 + ], 96 105 ), 97 106 render_activity_section(data.record_activity), 98 107 render_collections_section( 99 108 data.collection_stats, 100 109 data.record_lexicons, 101 110 is_admin, 111 + backfilling, 102 112 ), 103 113 ], 104 114 current_user: current_user, ··· 160 170 } 161 171 } 162 172 163 - /// Render the combined statistics section 164 - fn render_stats_section( 165 - record_count: Int, 166 - lexicon_count: Int, 167 - actor_count: Int, 168 - ) -> Element(msg) { 169 - html.div([attribute.class("mb-8 grid grid-cols-3 gap-4")], [ 170 - // Total records stat card 171 - html.div([attribute.class("bg-zinc-800/50 rounded p-4")], [ 172 - html.div([attribute.class("text-sm text-zinc-500 mb-1")], [ 173 - element.text("Total Records"), 174 - ]), 175 - html.div([attribute.class("text-2xl font-semibold text-zinc-200")], [ 176 - element.text(format.format_number(record_count)), 177 - ]), 178 - ]), 179 - // Actors stat card 180 - html.div([attribute.class("bg-zinc-800/50 rounded p-4")], [ 181 - html.div([attribute.class("text-sm text-zinc-500 mb-1")], [ 182 - element.text("Total Actors"), 183 - ]), 184 - html.div([attribute.class("text-2xl font-semibold text-zinc-200")], [ 185 - element.text(format.format_number(actor_count)), 186 - ]), 187 - ]), 188 - // Lexicons stat card 189 - html.div([attribute.class("bg-zinc-800/50 rounded p-4")], [ 190 - html.div([attribute.class("text-sm text-zinc-500 mb-1")], [ 191 - element.text("Total Lexicons"), 192 - ]), 193 - html.div([attribute.class("text-2xl font-semibold text-zinc-200")], [ 194 - element.text(format.format_number(lexicon_count)), 195 - ]), 196 - ]), 197 - ]) 198 - } 199 - 200 173 /// Render the activity chart section 201 174 fn render_activity_section( 202 175 activity: List(database.ActivityPoint), ··· 216 189 collection_stats: List(database.CollectionStat), 217 190 record_lexicons: List(database.Lexicon), 218 191 is_admin: Bool, 192 + backfilling: Bool, 219 193 ) -> Element(msg) { 220 194 let backfill_button = case is_admin { 221 195 True -> ··· 224 198 attribute.id("backfill-button"), 225 199 server_component.route("/backfill-ws"), 226 200 ], 227 - [], 201 + [backfill_button.render_button_static(is_admin, backfilling)], 228 202 ) 229 203 False -> element.none() 230 204 }
+39 -5
server/src/server.gleam
··· 11 11 import gleam/int 12 12 import gleam/list 13 13 import gleam/option 14 + import gleam/otp/actor 14 15 import gleam/string 15 16 import graphiql_handler 16 17 import graphql_handler ··· 28 29 import pubsub 29 30 import settings_handler 30 31 import sqlight 32 + import stats_pubsub 31 33 import upload_handler 32 34 import wisp 33 35 import wisp/wisp_mist ··· 227 229 // Initialize PubSub registry for subscriptions 228 230 pubsub.start() 229 231 logging.log(logging.Info, "[server] PubSub registry initialized") 232 + 233 + // Initialize Stats PubSub registry for real-time stats 234 + stats_pubsub.start() 235 + logging.log(logging.Info, "[server] Stats PubSub registry initialized") 230 236 231 237 // Start Jetstream consumer in background 232 238 let jetstream_subject = case jetstream_consumer.start(db) { ··· 473 479 // Serve Lustre client runtime 474 480 ["lustre", "runtime.mjs"] -> lustre_handlers.serve_lustre_runtime() 475 481 482 + // Serve styles CSS 483 + ["styles.css"] -> lustre_handlers.serve_tailwind_css() 484 + 476 485 // Backfill button WebSocket 477 486 ["backfill-ws"] -> { 478 487 case upgrade_header { 479 488 Ok(upgrade_value) -> { 480 489 case string.lowercase(upgrade_value) { 481 490 "websocket" -> { 482 - logging.log( 483 - logging.Info, 484 - "[server] WebSocket upgrade for /backfill-ws", 485 - ) 486 491 lustre_handlers.serve_backfill_button( 487 492 req, 488 493 ctx.db, ··· 497 502 } 498 503 } 499 504 505 + // Stats cards WebSocket 506 + ["stats-ws"] -> { 507 + case upgrade_header { 508 + Ok(upgrade_value) -> { 509 + case string.lowercase(upgrade_value) { 510 + "websocket" -> { 511 + lustre_handlers.serve_stats_cards(req, ctx.db) 512 + } 513 + _ -> wisp_handler(req) 514 + } 515 + } 516 + _ -> wisp_handler(req) 517 + } 518 + } 519 + 500 520 // GraphQL WebSocket 501 521 ["graphql"] | ["", "graphql"] -> { 502 522 case upgrade_header { ··· 758 778 // Get domain authority from config cache 759 779 let domain_authority = config.get_domain_authority(ctx.config) 760 780 761 - index.view(ctx.db, current_user, user_is_admin, domain_authority) 781 + // Get backfill state 782 + let backfilling = 783 + actor.call( 784 + ctx.backfill_state, 785 + waiting: 100, 786 + sending: backfill_state.IsBackfilling, 787 + ) 788 + 789 + index.view( 790 + ctx.db, 791 + current_user, 792 + user_is_admin, 793 + domain_authority, 794 + backfilling, 795 + ) 762 796 |> element.to_document_string 763 797 |> wisp.html_response(200) 764 798 }
+54
server/src/stats_pubsub.gleam
··· 1 + import gleam/erlang/process.{type Subject} 2 + import gleam/list 3 + import group_registry 4 + 5 + /// Event types for stats updates 6 + pub type StatsEvent { 7 + RecordCreated 8 + RecordDeleted 9 + ActorCreated 10 + } 11 + 12 + /// The group name for all stats event subscriptions 13 + const group_name = "stats_events" 14 + 15 + // Global registry name - must be created once and reused 16 + @external(erlang, "stats_pubsub_ffi", "get_registry_name") 17 + fn registry_name() -> process.Name(group_registry.Message(StatsEvent)) 18 + 19 + /// Initialize the Stats PubSub registry 20 + /// Must be called once when the server starts 21 + pub fn start() -> Nil { 22 + // Start the registry (idempotent - safe to call multiple times) 23 + let _ = group_registry.start(registry_name()) 24 + Nil 25 + } 26 + 27 + fn get_registry() -> group_registry.GroupRegistry(StatsEvent) { 28 + // Get existing registry 29 + group_registry.get_registry(registry_name()) 30 + } 31 + 32 + /// Subscribe to stats events 33 + /// Returns a Subject that will receive StatsEvent messages 34 + pub fn subscribe() -> Subject(StatsEvent) { 35 + let registry = get_registry() 36 + let my_pid = process.self() 37 + group_registry.join(registry, group_name, my_pid) 38 + } 39 + 40 + /// Unsubscribe from stats events 41 + pub fn unsubscribe(subscriber: Subject(StatsEvent)) -> Nil { 42 + let registry = get_registry() 43 + case process.subject_owner(subscriber) { 44 + Ok(my_pid) -> group_registry.leave(registry, group_name, [my_pid]) 45 + Error(_) -> Nil 46 + } 47 + } 48 + 49 + /// Publish a stats event to all subscribers 50 + pub fn publish(event: StatsEvent) -> Nil { 51 + let registry = get_registry() 52 + let subscribers = group_registry.members(registry, group_name) 53 + list.each(subscribers, fn(sub) { process.send(sub, event) }) 54 + }
+13
server/src/stats_pubsub_ffi.erl
··· 1 + -module(stats_pubsub_ffi). 2 + -export([get_registry_name/0]). 3 + 4 + %% Get the registry name, creating it once and caching it 5 + get_registry_name() -> 6 + case persistent_term:get({stats_pubsub, registry_name}, undefined) of 7 + undefined -> 8 + Name = gleam@erlang@process:new_name(<<"quickslice_stats_pubsub_registry">>), 9 + persistent_term:put({stats_pubsub, registry_name}, Name), 10 + Name; 11 + Name -> 12 + Name 13 + end.