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.

format

+724 -503
+11 -2
client/src/components/activity_chart.gleam
··· 162 162 attribute.attribute("preserveAspectRatio", "none"), 163 163 ], 164 164 list.index_map(buckets, fn(bucket, index) { 165 - render_stacked_bar(bucket, index, bar_width, gap, chart_height, max_value) 165 + render_stacked_bar( 166 + bucket, 167 + index, 168 + bar_width, 169 + gap, 170 + chart_height, 171 + max_value, 172 + ) 166 173 }), 167 174 ), 168 175 ]) 169 176 } 170 177 171 - fn calculate_max_value(buckets: List(get_activity_buckets.ActivityBucket)) -> Int { 178 + fn calculate_max_value( 179 + buckets: List(get_activity_buckets.ActivityBucket), 180 + ) -> Int { 172 181 buckets 173 182 |> list.map(fn(b) { b.creates + b.updates + b.deletes }) 174 183 |> list.reduce(int.max)
+88 -41
client/src/components/activity_log.gleam
··· 17 17 /// } 18 18 /// ``` 19 19 import date_formatter 20 + import generated/queries/get_recent_activity 20 21 import gleam/int 21 22 import gleam/json 22 23 import gleam/list 23 24 import gleam/option 24 - import generated/queries/get_recent_activity 25 25 import json_formatter 26 26 import lustre/attribute 27 27 import lustre/element.{type Element} ··· 41 41 42 42 html.div([attribute.class("font-mono mb-8")], [ 43 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); } 44 + element.element("style", [], [ 45 + element.text( 46 + "[data-entry-id].expanded [data-caret] { transform: rotate(90deg); } 50 47 [data-entry-id].expanded [data-details] { display: block !important; }", 51 - ), 52 - ], 53 - ), 48 + ), 49 + ]), 54 50 html.div([attribute.class("bg-zinc-800/50 rounded p-4")], [ 55 51 // Header 56 52 html.div([attribute.class("flex items-center justify-between mb-3")], [ ··· 77 73 html.div([attribute.class("max-h-80 overflow-y-auto")], [ 78 74 case result { 79 75 squall_cache.Loading -> 80 - html.div([attribute.class("py-8 text-center text-zinc-600 text-xs")], [ 81 - element.text("Loading activity..."), 82 - ]) 76 + html.div( 77 + [attribute.class("py-8 text-center text-zinc-600 text-xs")], 78 + [ 79 + element.text("Loading activity..."), 80 + ], 81 + ) 83 82 84 83 squall_cache.Failed(msg) -> 85 - html.div([attribute.class("py-8 text-center text-red-400 text-xs")], [ 86 - element.text("Error: " <> msg), 87 - ]) 84 + html.div( 85 + [attribute.class("py-8 text-center text-red-400 text-xs")], 86 + [ 87 + element.text("Error: " <> msg), 88 + ], 89 + ) 88 90 89 91 squall_cache.Data(data) -> { 90 92 case data.recent_activity { 91 93 [] -> 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 - ]) 94 + html.div( 95 + [attribute.class("py-8 text-center text-zinc-600 text-xs")], 96 + [ 97 + element.text( 98 + "No activity in the last " 99 + <> int.to_string(hours) 100 + <> " hours", 101 + ), 102 + ], 103 + ) 95 104 entries -> render_activity_entries(entries) 96 105 } 97 106 } ··· 134 143 135 144 html.div( 136 145 [ 137 - attribute.class("border-l-2 border-zinc-700/50 hover:border-zinc-600 transition-colors"), 146 + attribute.class( 147 + "border-l-2 border-zinc-700/50 hover:border-zinc-600 transition-colors", 148 + ), 138 149 attribute.attribute("data-entry-id", entry_id), 139 150 ], 140 151 [ 141 152 // Main log line 142 153 html.div( 143 154 [ 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')"), 155 + attribute.class( 156 + "flex items-start gap-2 py-1 text-xs font-mono hover:bg-zinc-900/30 cursor-pointer group", 157 + ), 158 + attribute.attribute( 159 + "onclick", 160 + "this.parentElement.classList.toggle('expanded')", 161 + ), 146 162 ], 147 163 [ 148 164 // Caret for expansion (always visible) 149 165 html.span( 150 166 [ 151 - attribute.class("text-zinc-600 group-hover:text-zinc-400 shrink-0 select-none transition-transform caret"), 167 + attribute.class( 168 + "text-zinc-600 group-hover:text-zinc-400 shrink-0 select-none transition-transform caret", 169 + ), 152 170 attribute.attribute("data-caret", ""), 153 171 ], 154 172 [element.text("›")], ··· 159 177 attribute.class("text-zinc-600 shrink-0 w-16"), 160 178 attribute.attribute("data-timestamp", entry.timestamp), 161 179 ], 162 - [element.text(date_formatter.format_time_local(entry.timestamp))], 180 + [ 181 + element.text(date_formatter.format_time_local(entry.timestamp)), 182 + ], 163 183 ), 164 184 // Status icon 165 185 html.span([attribute.class(status_color <> " shrink-0 w-4")], [ ··· 182 202 // Expanded details section - shows all fields and JSON snippet 183 203 html.div( 184 204 [ 185 - attribute.class("px-6 py-2 text-xs bg-zinc-900/50 border-t border-zinc-800 hidden space-y-1"), 205 + attribute.class( 206 + "px-6 py-2 text-xs bg-zinc-900/50 border-t border-zinc-800 hidden space-y-1", 207 + ), 186 208 attribute.attribute("data-details", ""), 187 209 ], 188 210 [ 189 211 // Full timestamp in local timezone 190 212 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))]), 213 + html.span([attribute.class("text-zinc-600 w-20")], [ 214 + element.text("Timestamp:"), 215 + ]), 216 + html.span([attribute.class("text-zinc-400")], [ 217 + element.text(date_formatter.format_datetime_local( 218 + entry.timestamp, 219 + )), 220 + ]), 193 221 ]), 194 222 // Full DID 195 223 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)]), 224 + html.span([attribute.class("text-zinc-600 w-20")], [ 225 + element.text("DID:"), 226 + ]), 227 + html.span( 228 + [attribute.class("text-zinc-400 font-mono break-all")], 229 + [element.text(entry.did)], 230 + ), 198 231 ]), 199 232 // Status 200 233 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)]), 234 + html.span([attribute.class("text-zinc-600 w-20")], [ 235 + element.text("Status:"), 236 + ]), 237 + html.span( 238 + [ 239 + attribute.class(case entry.status { 240 + "success" -> "text-green-400" 241 + "validation_error" -> "text-yellow-400" 242 + "error" -> "text-red-400" 243 + _ -> "text-zinc-400" 244 + }), 245 + ], 246 + [element.text(entry.status)], 247 + ), 208 248 ]), 209 249 // Error message (if present) 210 250 case entry.error_message { 211 251 option.Some(err_msg) -> 212 252 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)]), 253 + html.span([attribute.class("text-zinc-600 w-20")], [ 254 + element.text("Error:"), 255 + ]), 256 + html.span([attribute.class("text-red-400")], [ 257 + element.text(err_msg), 258 + ]), 215 259 ]) 216 260 option.None -> element.none() 217 261 }, ··· 220 264 option.Some(json_str) -> { 221 265 let pretty_json = json_formatter.pretty_print_nested(json_str) 222 266 html.div([attribute.class("mt-2")], [ 223 - html.div([attribute.class("text-zinc-600 mb-1")], [element.text("Event JSON:")]), 267 + html.div([attribute.class("text-zinc-600 mb-1")], [ 268 + element.text("Event JSON:"), 269 + ]), 224 270 html.pre( 225 271 [ 226 - attribute.class("text-zinc-400 bg-black/40 p-2 rounded text-[10px] whitespace-pre-wrap block"), 272 + attribute.class( 273 + "text-zinc-400 bg-black/40 p-2 rounded text-[10px] whitespace-pre-wrap block", 274 + ), 227 275 attribute.attribute("data-json", json_str), 228 276 ], 229 277 [element.text(pretty_json)], ··· 239 287 }), 240 288 ) 241 289 } 242 -
+12 -2
client/src/components/alert.gleam
··· 15 15 Success -> #("bg-green-900/30", "border-green-800", "text-green-300", "✓") 16 16 Error -> #("bg-red-900/30", "border-red-800", "text-red-300", "✗") 17 17 Info -> #("bg-blue-900/30", "border-blue-800", "text-blue-300", "ℹ") 18 - Warning -> #("bg-yellow-900/30", "border-yellow-800", "text-yellow-300", "⚠") 18 + Warning -> #( 19 + "bg-yellow-900/30", 20 + "border-yellow-800", 21 + "text-yellow-300", 22 + "⚠", 23 + ) 19 24 } 20 25 21 26 html.div( ··· 48 53 Success -> #("bg-green-900/30", "border-green-800", "text-green-300", "✓") 49 54 Error -> #("bg-red-900/30", "border-red-800", "text-red-300", "✗") 50 55 Info -> #("bg-blue-900/30", "border-blue-800", "text-blue-300", "ℹ") 51 - Warning -> #("bg-yellow-900/30", "border-yellow-800", "text-yellow-300", "⚠") 56 + Warning -> #( 57 + "bg-yellow-900/30", 58 + "border-yellow-800", 59 + "text-yellow-300", 60 + "⚠", 61 + ) 52 62 } 53 63 54 64 html.div(
+77 -74
client/src/components/layout.gleam
··· 33 33 ], 34 34 ), 35 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 36 + html.div( 37 + [attribute.class("flex gap-4 text-xs items-center")], 38 + case auth_info { 39 + option.None -> [ 40 + // Only show login form when not authenticated 104 41 html.form( 105 42 [ 106 43 attribute.method("POST"), 107 - attribute.action("/logout"), 44 + attribute.action("/oauth/authorize"), 45 + attribute.class("flex gap-2 items-center"), 108 46 ], 109 47 [ 48 + html.input([ 49 + attribute.type_("text"), 50 + attribute.name("login_hint"), 51 + attribute.placeholder("handle.bsky.social"), 52 + attribute.class( 53 + "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", 54 + ), 55 + attribute.required(True), 56 + ]), 110 57 html.button( 111 58 [ 112 59 attribute.type_("submit"), 113 60 attribute.class( 114 - "px-3 py-1 text-zinc-400 hover:text-zinc-300 transition-colors cursor-pointer", 61 + "bg-zinc-800 hover:bg-zinc-700 text-zinc-300 px-3 py-1 rounded transition-colors", 115 62 ), 116 63 ], 117 - [element.text("Logout")], 64 + [element.text("Login")], 118 65 ), 119 66 ], 120 67 ), 121 - ]) 122 - } 123 - }), 68 + ] 69 + option.Some(#(handle, is_admin)) -> { 70 + // Show navigation links when authenticated 71 + let nav_links = [ 72 + html.a( 73 + [ 74 + attribute.href("/"), 75 + attribute.class( 76 + "px-3 py-1 text-zinc-400 hover:text-zinc-300 transition-colors", 77 + ), 78 + ], 79 + [element.text("Home")], 80 + ), 81 + ] 82 + 83 + // Add Settings link only for admins 84 + let nav_links = case is_admin { 85 + True -> 86 + list.append(nav_links, [ 87 + html.a( 88 + [ 89 + attribute.href("/settings"), 90 + attribute.class( 91 + "px-3 py-1 text-zinc-400 hover:text-zinc-300 transition-colors", 92 + ), 93 + ], 94 + [element.text("Settings")], 95 + ), 96 + ]) 97 + False -> nav_links 98 + } 99 + 100 + list.append(nav_links, [ 101 + // User handle 102 + html.span([attribute.class("px-3 py-1 text-zinc-400")], [ 103 + element.text(handle), 104 + ]), 105 + // Logout button 106 + html.form( 107 + [ 108 + attribute.method("POST"), 109 + attribute.action("/logout"), 110 + ], 111 + [ 112 + html.button( 113 + [ 114 + attribute.type_("submit"), 115 + attribute.class( 116 + "px-3 py-1 text-zinc-400 hover:text-zinc-300 transition-colors cursor-pointer", 117 + ), 118 + ], 119 + [element.text("Logout")], 120 + ), 121 + ], 122 + ), 123 + ]) 124 + } 125 + }, 126 + ), 124 127 ]), 125 128 ]) 126 129 }
+13 -4
client/src/components/stats_cards.gleam
··· 11 11 /// } 12 12 /// } 13 13 /// ``` 14 - import gleam/json 15 14 import generated/queries/get_statistics 15 + import gleam/json 16 16 import lustre/attribute 17 17 import lustre/element.{type Element} 18 18 import lustre/element/html ··· 47 47 let stats = data.statistics 48 48 49 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)), 50 + stat_card( 51 + "Total Records", 52 + number_formatter.format_number(stats.record_count), 53 + ), 54 + stat_card( 55 + "Total Actors", 56 + number_formatter.format_number(stats.actor_count), 57 + ), 58 + stat_card( 59 + "Total Lexicons", 60 + number_formatter.format_number(stats.lexicon_count), 61 + ), 53 62 ]) 54 63 } 55 64 }
-1
client/src/date_formatter.gleam
··· 1 1 /// Date formatting utilities via JavaScript FFI 2 - 3 2 /// Format an ISO8601 timestamp to local time (HH:MM:SS in user's timezone) 4 3 @external(javascript, "./date_formatter.ffi.mjs", "formatTimeLocal") 5 4 pub fn format_time_local(iso_timestamp: String) -> String
-1
client/src/file_upload.gleam
··· 1 1 /// File upload utilities via JavaScript FFI 2 2 /// 3 3 /// Provides base64 encoding for file uploads through GraphQL 4 - 5 4 /// Read a file and encode it as base64 6 5 /// This is async and uses a callback (dispatch function) to return the result 7 6 @external(javascript, "./file_upload.ffi.mjs", "readFileAsBase64")
+64 -55
client/src/generated/queries.gleam
··· 4 4 /// This function is auto-generated by Squall 5 5 pub fn init_registry() -> unstable_registry.Registry { 6 6 let reg = unstable_registry.new() 7 - let reg = unstable_registry.register( 8 - reg, 9 - "TriggerBackfill", 10 - "mutation TriggerBackfill {\n triggerBackfill\n}", 11 - "generated/queries/trigger_backfill", 12 - ) 13 - let reg = unstable_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 = unstable_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 = unstable_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 = unstable_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 = unstable_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 = unstable_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 = unstable_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 = unstable_registry.register( 56 - reg, 57 - "ResetAll", 58 - "mutation ResetAll($confirm: String!) {\n resetAll(confirm: $confirm)\n}", 59 - "generated/queries/reset_all", 60 - ) 7 + let reg = 8 + unstable_registry.register( 9 + reg, 10 + "TriggerBackfill", 11 + "mutation TriggerBackfill {\n triggerBackfill\n}", 12 + "generated/queries/trigger_backfill", 13 + ) 14 + let reg = 15 + unstable_registry.register( 16 + reg, 17 + "GetCurrentSession", 18 + "query GetCurrentSession {\n currentSession {\n __typename\n did\n handle\n isAdmin\n }\n}", 19 + "generated/queries/get_current_session", 20 + ) 21 + let reg = 22 + unstable_registry.register( 23 + reg, 24 + "GetActivityBuckets", 25 + "query GetActivityBuckets($range: TimeRange!) {\n activityBuckets(range: $range) {\n __typename\n timestamp\n total\n creates\n updates\n deletes\n }\n}", 26 + "generated/queries/get_activity_buckets", 27 + ) 28 + let reg = 29 + unstable_registry.register( 30 + reg, 31 + "GetRecentActivity", 32 + "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}", 33 + "generated/queries/get_recent_activity", 34 + ) 35 + let reg = 36 + unstable_registry.register( 37 + reg, 38 + "GetStatistics", 39 + "query GetStatistics {\n statistics {\n __typename\n recordCount\n actorCount\n lexiconCount\n }\n}", 40 + "generated/queries/get_statistics", 41 + ) 42 + let reg = 43 + unstable_registry.register( 44 + reg, 45 + "GetSettings", 46 + "query GetSettings {\n settings {\n __typename\n id\n domainAuthority\n oauthClientId\n }\n}", 47 + "generated/queries/get_settings", 48 + ) 49 + let reg = 50 + unstable_registry.register( 51 + reg, 52 + "UpdateDomainAuthority", 53 + "mutation UpdateDomainAuthority($domainAuthority: String!) {\n updateDomainAuthority(domainAuthority: $domainAuthority) {\n __typename\n id\n domainAuthority\n oauthClientId\n }\n}", 54 + "generated/queries/update_domain_authority", 55 + ) 56 + let reg = 57 + unstable_registry.register( 58 + reg, 59 + "UploadLexicons", 60 + "mutation UploadLexicons($zipBase64: String!) {\n uploadLexicons(zipBase64: $zipBase64)\n}", 61 + "generated/queries/upload_lexicons", 62 + ) 63 + let reg = 64 + unstable_registry.register( 65 + reg, 66 + "ResetAll", 67 + "mutation ResetAll($confirm: String!) {\n resetAll(confirm: $confirm)\n}", 68 + "generated/queries/reset_all", 69 + ) 61 70 reg 62 - } 71 + }
+30 -27
client/src/generated/queries/get_activity_buckets.gleam
··· 23 23 24 24 pub fn time_range_decoder() -> decode.Decoder(TimeRange) { 25 25 decode.string 26 - 27 - 28 26 |> decode.then(fn(str) { 29 - 30 27 case str { 31 28 "ONE_HOUR" -> decode.success(ONEHOUR) 32 29 "THREE_HOURS" -> decode.success(THREEHOURS) ··· 35 32 "SEVEN_DAYS" -> decode.success(SEVENDAYS) 36 33 _other -> decode.failure(ONEHOUR, "TimeRange") 37 34 } 38 - 39 - 40 35 }) 41 36 } 42 37 ··· 66 61 } 67 62 68 63 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 - ) 64 + json.object([ 65 + #("timestamp", json.string(input.timestamp)), 66 + #("total", json.int(input.total)), 67 + #("creates", json.int(input.creates)), 68 + #("updates", json.int(input.updates)), 69 + #("deletes", json.int(input.deletes)), 70 + ]) 78 71 } 79 72 80 73 pub type GetActivityBucketsResponse { 81 74 GetActivityBucketsResponse(activity_buckets: List(ActivityBucket)) 82 75 } 83 76 84 - pub fn get_activity_buckets_response_decoder() -> decode.Decoder(GetActivityBucketsResponse) { 85 - use activity_buckets <- decode.field("activityBuckets", decode.list(activity_bucket_decoder())) 77 + pub fn get_activity_buckets_response_decoder() -> decode.Decoder( 78 + GetActivityBucketsResponse, 79 + ) { 80 + use activity_buckets <- decode.field( 81 + "activityBuckets", 82 + decode.list(activity_bucket_decoder()), 83 + ) 86 84 decode.success(GetActivityBucketsResponse(activity_buckets: activity_buckets)) 87 85 } 88 86 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 - ) 87 + pub fn get_activity_buckets_response_to_json( 88 + input: GetActivityBucketsResponse, 89 + ) -> json.Json { 90 + json.object([ 91 + #( 92 + "activityBuckets", 93 + json.array(from: input.activity_buckets, of: activity_bucket_to_json), 94 + ), 95 + ]) 98 96 } 99 97 100 - pub fn get_activity_buckets(client: squall.Client, range: TimeRange) -> Result(Request(String), String) { 98 + pub fn get_activity_buckets( 99 + client: squall.Client, 100 + range: TimeRange, 101 + ) -> Result(Request(String), String) { 101 102 squall.prepare_request( 102 103 client, 103 104 "query GetActivityBuckets($range: TimeRange!) {\n activityBuckets(range: $range) {\n timestamp\n total\n creates\n updates\n deletes\n }\n}", ··· 105 106 ) 106 107 } 107 108 108 - pub fn parse_get_activity_buckets_response(body: String) -> Result(GetActivityBucketsResponse, String) { 109 + pub fn parse_get_activity_buckets_response( 110 + body: String, 111 + ) -> Result(GetActivityBucketsResponse, String) { 109 112 squall.parse_response(body, get_activity_buckets_response_decoder()) 110 113 }
+28 -21
client/src/generated/queries/get_current_session.gleam
··· 1 1 import gleam/dynamic/decode 2 2 import gleam/http/request.{type Request} 3 3 import gleam/json 4 - import squall 5 4 import gleam/option.{type Option} 5 + import squall 6 6 7 7 pub type CurrentSession { 8 8 CurrentSession(did: String, handle: String, is_admin: Bool) ··· 16 16 } 17 17 18 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 - ) 19 + json.object([ 20 + #("did", json.string(input.did)), 21 + #("handle", json.string(input.handle)), 22 + #("isAdmin", json.bool(input.is_admin)), 23 + ]) 26 24 } 27 25 28 26 pub type GetCurrentSessionResponse { 29 27 GetCurrentSessionResponse(current_session: Option(CurrentSession)) 30 28 } 31 29 32 - pub fn get_current_session_response_decoder() -> decode.Decoder(GetCurrentSessionResponse) { 33 - use current_session <- decode.field("currentSession", decode.optional(current_session_decoder())) 30 + pub fn get_current_session_response_decoder() -> decode.Decoder( 31 + GetCurrentSessionResponse, 32 + ) { 33 + use current_session <- decode.field( 34 + "currentSession", 35 + decode.optional(current_session_decoder()), 36 + ) 34 37 decode.success(GetCurrentSessionResponse(current_session: current_session)) 35 38 } 36 39 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 - ) 40 + pub fn get_current_session_response_to_json( 41 + input: GetCurrentSessionResponse, 42 + ) -> json.Json { 43 + json.object([ 44 + #( 45 + "currentSession", 46 + json.nullable(input.current_session, current_session_to_json), 47 + ), 48 + ]) 46 49 } 47 50 48 - pub fn get_current_session(client: squall.Client) -> Result(Request(String), String) { 51 + pub fn get_current_session( 52 + client: squall.Client, 53 + ) -> Result(Request(String), String) { 49 54 squall.prepare_request( 50 55 client, 51 56 "query GetCurrentSession {\n currentSession {\n did\n handle\n isAdmin\n }\n}", ··· 53 58 ) 54 59 } 55 60 56 - pub fn parse_get_current_session_response(body: String) -> Result(GetCurrentSessionResponse, String) { 61 + pub fn parse_get_current_session_response( 62 + body: String, 63 + ) -> Result(GetCurrentSessionResponse, String) { 57 64 squall.parse_response(body, get_current_session_response_decoder()) 58 65 }
+38 -27
client/src/generated/queries/get_recent_activity.gleam
··· 1 1 import gleam/dynamic/decode 2 2 import gleam/http/request.{type Request} 3 3 import gleam/json 4 - import squall 5 4 import gleam/option.{type Option} 5 + import squall 6 6 7 7 pub type ActivityEntry { 8 8 ActivityEntry( ··· 24 24 use collection <- decode.field("collection", decode.string) 25 25 use did <- decode.field("did", decode.string) 26 26 use status <- decode.field("status", decode.string) 27 - use error_message <- decode.field("errorMessage", decode.optional(decode.string)) 27 + use error_message <- decode.field( 28 + "errorMessage", 29 + decode.optional(decode.string), 30 + ) 28 31 use event_json <- decode.field("eventJson", decode.optional(decode.string)) 29 32 decode.success(ActivityEntry( 30 33 id: id, ··· 39 42 } 40 43 41 44 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 - ) 45 + json.object([ 46 + #("id", json.int(input.id)), 47 + #("timestamp", json.string(input.timestamp)), 48 + #("operation", json.string(input.operation)), 49 + #("collection", json.string(input.collection)), 50 + #("did", json.string(input.did)), 51 + #("status", json.string(input.status)), 52 + #("errorMessage", json.nullable(input.error_message, json.string)), 53 + #("eventJson", json.nullable(input.event_json, json.string)), 54 + ]) 54 55 } 55 56 56 57 pub type GetRecentActivityResponse { 57 58 GetRecentActivityResponse(recent_activity: List(ActivityEntry)) 58 59 } 59 60 60 - pub fn get_recent_activity_response_decoder() -> decode.Decoder(GetRecentActivityResponse) { 61 - use recent_activity <- decode.field("recentActivity", decode.list(activity_entry_decoder())) 61 + pub fn get_recent_activity_response_decoder() -> decode.Decoder( 62 + GetRecentActivityResponse, 63 + ) { 64 + use recent_activity <- decode.field( 65 + "recentActivity", 66 + decode.list(activity_entry_decoder()), 67 + ) 62 68 decode.success(GetRecentActivityResponse(recent_activity: recent_activity)) 63 69 } 64 70 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 - ) 71 + pub fn get_recent_activity_response_to_json( 72 + input: GetRecentActivityResponse, 73 + ) -> json.Json { 74 + json.object([ 75 + #( 76 + "recentActivity", 77 + json.array(from: input.recent_activity, of: activity_entry_to_json), 78 + ), 79 + ]) 74 80 } 75 81 76 - pub fn get_recent_activity(client: squall.Client, hours: Int) -> Result(Request(String), String) { 82 + pub fn get_recent_activity( 83 + client: squall.Client, 84 + hours: Int, 85 + ) -> Result(Request(String), String) { 77 86 squall.prepare_request( 78 87 client, 79 88 "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}", ··· 81 90 ) 82 91 } 83 92 84 - pub fn parse_get_recent_activity_response(body: String) -> Result(GetRecentActivityResponse, String) { 93 + pub fn parse_get_recent_activity_response( 94 + body: String, 95 + ) -> Result(GetRecentActivityResponse, String) { 85 96 squall.parse_response(body, get_recent_activity_response_decoder()) 86 97 }
+13 -10
client/src/generated/queries/get_settings.gleam
··· 1 1 import gleam/dynamic/decode 2 2 import gleam/http/request.{type Request} 3 3 import gleam/json 4 - import squall 5 4 import gleam/option.{type Option} 5 + import squall 6 6 7 7 pub type Settings { 8 8 Settings( ··· 15 15 pub fn settings_decoder() -> decode.Decoder(Settings) { 16 16 use id <- decode.field("id", decode.string) 17 17 use domain_authority <- decode.field("domainAuthority", decode.string) 18 - use oauth_client_id <- decode.field("oauthClientId", decode.optional(decode.string)) 18 + use oauth_client_id <- decode.field( 19 + "oauthClientId", 20 + decode.optional(decode.string), 21 + ) 19 22 decode.success(Settings( 20 23 id: id, 21 24 domain_authority: domain_authority, ··· 24 27 } 25 28 26 29 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 - ) 30 + json.object([ 31 + #("id", json.string(input.id)), 32 + #("domainAuthority", json.string(input.domain_authority)), 33 + #("oauthClientId", json.nullable(input.oauth_client_id, json.string)), 34 + ]) 34 35 } 35 36 36 37 pub type GetSettingsResponse { ··· 54 55 ) 55 56 } 56 57 57 - pub fn parse_get_settings_response(body: String) -> Result(GetSettingsResponse, String) { 58 + pub fn parse_get_settings_response( 59 + body: String, 60 + ) -> Result(GetSettingsResponse, String) { 58 61 squall.parse_response(body, get_settings_response_decoder()) 59 62 }
+14 -10
client/src/generated/queries/get_statistics.gleam
··· 19 19 } 20 20 21 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 - ) 22 + json.object([ 23 + #("recordCount", json.int(input.record_count)), 24 + #("actorCount", json.int(input.actor_count)), 25 + #("lexiconCount", json.int(input.lexicon_count)), 26 + ]) 29 27 } 30 28 31 29 pub type GetStatisticsResponse { 32 30 GetStatisticsResponse(statistics: Statistics) 33 31 } 34 32 35 - pub fn get_statistics_response_decoder() -> decode.Decoder(GetStatisticsResponse) { 33 + pub fn get_statistics_response_decoder() -> decode.Decoder( 34 + GetStatisticsResponse, 35 + ) { 36 36 use statistics <- decode.field("statistics", statistics_decoder()) 37 37 decode.success(GetStatisticsResponse(statistics: statistics)) 38 38 } 39 39 40 - pub fn get_statistics_response_to_json(input: GetStatisticsResponse) -> json.Json { 40 + pub fn get_statistics_response_to_json( 41 + input: GetStatisticsResponse, 42 + ) -> json.Json { 41 43 json.object([#("statistics", statistics_to_json(input.statistics))]) 42 44 } 43 45 ··· 49 51 ) 50 52 } 51 53 52 - pub fn parse_get_statistics_response(body: String) -> Result(GetStatisticsResponse, String) { 54 + pub fn parse_get_statistics_response( 55 + body: String, 56 + ) -> Result(GetStatisticsResponse, String) { 53 57 squall.parse_response(body, get_statistics_response_decoder()) 54 58 }
+7 -2
client/src/generated/queries/reset_all.gleam
··· 16 16 json.object([#("resetAll", json.bool(input.reset_all))]) 17 17 } 18 18 19 - pub fn reset_all(client: squall.Client, confirm: String) -> Result(Request(String), String) { 19 + pub fn reset_all( 20 + client: squall.Client, 21 + confirm: String, 22 + ) -> Result(Request(String), String) { 20 23 squall.prepare_request( 21 24 client, 22 25 "mutation ResetAll($confirm: String!) {\n resetAll(confirm: $confirm)\n}", ··· 24 27 ) 25 28 } 26 29 27 - pub fn parse_reset_all_response(body: String) -> Result(ResetAllResponse, String) { 30 + pub fn parse_reset_all_response( 31 + body: String, 32 + ) -> Result(ResetAllResponse, String) { 28 33 squall.parse_response(body, reset_all_response_decoder()) 29 34 }
+12 -4
client/src/generated/queries/trigger_backfill.gleam
··· 7 7 TriggerBackfillResponse(trigger_backfill: Bool) 8 8 } 9 9 10 - pub fn trigger_backfill_response_decoder() -> decode.Decoder(TriggerBackfillResponse) { 10 + pub fn trigger_backfill_response_decoder() -> decode.Decoder( 11 + TriggerBackfillResponse, 12 + ) { 11 13 use trigger_backfill <- decode.field("triggerBackfill", decode.bool) 12 14 decode.success(TriggerBackfillResponse(trigger_backfill: trigger_backfill)) 13 15 } 14 16 15 - pub fn trigger_backfill_response_to_json(input: TriggerBackfillResponse) -> json.Json { 17 + pub fn trigger_backfill_response_to_json( 18 + input: TriggerBackfillResponse, 19 + ) -> json.Json { 16 20 json.object([#("triggerBackfill", json.bool(input.trigger_backfill))]) 17 21 } 18 22 19 - pub fn trigger_backfill(client: squall.Client) -> Result(Request(String), String) { 23 + pub fn trigger_backfill( 24 + client: squall.Client, 25 + ) -> Result(Request(String), String) { 20 26 squall.prepare_request( 21 27 client, 22 28 "mutation TriggerBackfill {\n triggerBackfill\n}", ··· 24 30 ) 25 31 } 26 32 27 - pub fn parse_trigger_backfill_response(body: String) -> Result(TriggerBackfillResponse, String) { 33 + pub fn parse_trigger_backfill_response( 34 + body: String, 35 + ) -> Result(TriggerBackfillResponse, String) { 28 36 squall.parse_response(body, trigger_backfill_response_decoder()) 29 37 }
+30 -19
client/src/generated/queries/update_domain_authority.gleam
··· 1 1 import gleam/dynamic/decode 2 2 import gleam/http/request.{type Request} 3 3 import gleam/json 4 - import squall 5 4 import gleam/option.{type Option} 5 + import squall 6 6 7 7 pub type Settings { 8 8 Settings( ··· 15 15 pub fn settings_decoder() -> decode.Decoder(Settings) { 16 16 use id <- decode.field("id", decode.string) 17 17 use domain_authority <- decode.field("domainAuthority", decode.string) 18 - use oauth_client_id <- decode.field("oauthClientId", decode.optional(decode.string)) 18 + use oauth_client_id <- decode.field( 19 + "oauthClientId", 20 + decode.optional(decode.string), 21 + ) 19 22 decode.success(Settings( 20 23 id: id, 21 24 domain_authority: domain_authority, ··· 24 27 } 25 28 26 29 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 - ) 30 + json.object([ 31 + #("id", json.string(input.id)), 32 + #("domainAuthority", json.string(input.domain_authority)), 33 + #("oauthClientId", json.nullable(input.oauth_client_id, json.string)), 34 + ]) 34 35 } 35 36 36 37 pub type UpdateDomainAuthorityResponse { 37 38 UpdateDomainAuthorityResponse(update_domain_authority: Settings) 38 39 } 39 40 40 - pub fn update_domain_authority_response_decoder() -> decode.Decoder(UpdateDomainAuthorityResponse) { 41 - use update_domain_authority <- decode.field("updateDomainAuthority", settings_decoder()) 41 + pub fn update_domain_authority_response_decoder() -> decode.Decoder( 42 + UpdateDomainAuthorityResponse, 43 + ) { 44 + use update_domain_authority <- decode.field( 45 + "updateDomainAuthority", 46 + settings_decoder(), 47 + ) 42 48 decode.success(UpdateDomainAuthorityResponse( 43 49 update_domain_authority: update_domain_authority, 44 50 )) 45 51 } 46 52 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 + pub fn update_domain_authority_response_to_json( 54 + input: UpdateDomainAuthorityResponse, 55 + ) -> json.Json { 56 + json.object([ 57 + #("updateDomainAuthority", settings_to_json(input.update_domain_authority)), 58 + ]) 53 59 } 54 60 55 - pub fn update_domain_authority(client: squall.Client, domain_authority: String) -> Result(Request(String), String) { 61 + pub fn update_domain_authority( 62 + client: squall.Client, 63 + domain_authority: String, 64 + ) -> Result(Request(String), String) { 56 65 squall.prepare_request( 57 66 client, 58 67 "mutation UpdateDomainAuthority($domainAuthority: String!) {\n updateDomainAuthority(domainAuthority: $domainAuthority) {\n id\n domainAuthority\n oauthClientId\n }\n}", ··· 60 69 ) 61 70 } 62 71 63 - pub fn parse_update_domain_authority_response(body: String) -> Result(UpdateDomainAuthorityResponse, String) { 72 + pub fn parse_update_domain_authority_response( 73 + body: String, 74 + ) -> Result(UpdateDomainAuthorityResponse, String) { 64 75 squall.parse_response(body, update_domain_authority_response_decoder()) 65 76 }
+13 -4
client/src/generated/queries/upload_lexicons.gleam
··· 7 7 UploadLexiconsResponse(upload_lexicons: Bool) 8 8 } 9 9 10 - pub fn upload_lexicons_response_decoder() -> decode.Decoder(UploadLexiconsResponse) { 10 + pub fn upload_lexicons_response_decoder() -> decode.Decoder( 11 + UploadLexiconsResponse, 12 + ) { 11 13 use upload_lexicons <- decode.field("uploadLexicons", decode.bool) 12 14 decode.success(UploadLexiconsResponse(upload_lexicons: upload_lexicons)) 13 15 } 14 16 15 - pub fn upload_lexicons_response_to_json(input: UploadLexiconsResponse) -> json.Json { 17 + pub fn upload_lexicons_response_to_json( 18 + input: UploadLexiconsResponse, 19 + ) -> json.Json { 16 20 json.object([#("uploadLexicons", json.bool(input.upload_lexicons))]) 17 21 } 18 22 19 - pub fn upload_lexicons(client: squall.Client, zip_base64: String) -> Result(Request(String), String) { 23 + pub fn upload_lexicons( 24 + client: squall.Client, 25 + zip_base64: String, 26 + ) -> Result(Request(String), String) { 20 27 squall.prepare_request( 21 28 client, 22 29 "mutation UploadLexicons($zipBase64: String!) {\n uploadLexicons(zipBase64: $zipBase64)\n}", ··· 24 31 ) 25 32 } 26 33 27 - pub fn parse_upload_lexicons_response(body: String) -> Result(UploadLexiconsResponse, String) { 34 + pub fn parse_upload_lexicons_response( 35 + body: String, 36 + ) -> Result(UploadLexiconsResponse, String) { 28 37 squall.parse_response(body, upload_lexicons_response_decoder()) 29 38 }
-1
client/src/json_formatter.gleam
··· 1 1 /// JSON formatting utilities via JavaScript FFI 2 - 3 2 /// Pretty-print a JSON string with indentation 4 3 @external(javascript, "./json_formatter.ffi.mjs", "prettyPrint") 5 4 pub fn pretty_print(json_string: String) -> String
-1
client/src/navigation.gleam
··· 1 1 /// Navigation helpers for external URLs 2 - 3 2 @external(javascript, "./navigation_ffi.mjs", "navigateToExternal") 4 3 pub fn navigate_to_external(url: String) -> Nil
-1
client/src/number_formatter.gleam
··· 1 1 /// Number formatting utilities via JavaScript FFI 2 - 3 2 /// Format a number with locale-specific thousands separators 4 3 @external(javascript, "./number_formatter.ffi.mjs", "formatNumber") 5 4 pub fn format_number(number: Int) -> String
+17 -7
client/src/pages/home.gleam
··· 48 48 // Extract domain authority and lexicon count for alerts 49 49 let alerts = case stats_result, settings_result { 50 50 squall_cache.Data(stats), squall_cache.Data(settings) -> 51 - render_alerts(settings.settings.domain_authority, stats.statistics.lexicon_count) 51 + render_alerts( 52 + settings.settings.domain_authority, 53 + stats.statistics.lexicon_count, 54 + ) 52 55 _, _ -> element.none() 53 56 } 54 57 ··· 58 61 // Action buttons 59 62 html.div([attribute.class("mb-8 flex gap-3")], case is_admin { 60 63 True -> [ 61 - button.button(disabled: False, on_click: OpenGraphiQL, text: "Open GraphiQL"), 64 + button.button( 65 + disabled: False, 66 + on_click: OpenGraphiQL, 67 + text: "Open GraphiQL", 68 + ), 62 69 button.button( 63 70 disabled: is_backfilling, 64 71 on_click: TriggerBackfill, ··· 68 75 }, 69 76 ), 70 77 ] 71 - False -> [button.button(disabled: False, on_click: OpenGraphiQL, text: "Open GraphiQL")] 78 + False -> [ 79 + button.button( 80 + disabled: False, 81 + on_click: OpenGraphiQL, 82 + text: "Open GraphiQL", 83 + ), 84 + ] 72 85 }), 73 86 // Stats cards component 74 87 stats_cards.view(cache), ··· 80 93 } 81 94 82 95 /// 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) { 96 + fn render_alerts(domain_authority: String, lexicon_count: Int) -> Element(Msg) { 87 97 let domain_alert = case domain_authority { 88 98 "" -> 89 99 alert.alert_with_link(
+51 -46
client/src/pages/settings.gleam
··· 123 123 html.h1([attribute.class("text-2xl font-semibold text-zinc-300 mb-8")], [ 124 124 element.text("Settings"), 125 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 - ]) 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( 142 + [attribute.class("py-8 text-center text-zinc-600 text-sm")], 143 + [ 144 + element.text("Loading settings..."), 145 + ], 146 + ) 144 147 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 - ]) 148 + squall_cache.Failed(msg) -> 149 + html.div( 150 + [attribute.class("py-8 text-center text-red-400 text-sm")], 151 + [ 152 + element.text("Error: " <> msg), 153 + ], 154 + ) 149 155 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 - }, 156 + squall_cache.Data(data) -> 157 + html.div([attribute.class("space-y-6")], [ 158 + domain_authority_section(data.settings, model, is_saving), 159 + lexicons_section(model), 160 + oauth_section(data.settings), 161 + danger_zone_section(model), 162 + ]) 163 + }, 158 164 ]) 159 165 } 160 166 } ··· 176 182 ]), 177 183 html.div([attribute.class("space-y-4")], [ 178 184 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 - ), 185 + html.label([attribute.class("block text-sm text-zinc-400 mb-2")], [ 186 + element.text("Domain Authority"), 187 + ]), 183 188 html.input([ 184 189 attribute.type_("text"), 185 190 attribute.class( ··· 207 212 attribute.disabled(is_saving), 208 213 event.on_click(SubmitDomainAuthority), 209 214 ], 210 - [element.text(case is_saving { 211 - True -> "Saving..." 212 - False -> "Save" 213 - })], 215 + [ 216 + element.text(case is_saving { 217 + True -> "Saving..." 218 + False -> "Save" 219 + }), 220 + ], 214 221 ), 215 222 ]), 216 223 ]), ··· 270 277 ]), 271 278 html.div([attribute.class("space-y-4")], [ 272 279 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 - ), 280 + html.label([attribute.class("block text-sm text-zinc-400 mb-2")], [ 281 + element.text("Upload Lexicons (ZIP)"), 282 + ]), 277 283 html.input([ 278 284 attribute.type_("file"), 279 285 attribute.accept([".zip"]), ··· 324 330 ]), 325 331 html.div([attribute.class("space-y-4")], [ 326 332 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 - ), 333 + html.label([attribute.class("block text-sm text-zinc-400 mb-2")], [ 334 + element.text("Type RESET to confirm"), 335 + ]), 331 336 html.input([ 332 337 attribute.type_("text"), 333 338 attribute.class(
+2 -6
server/src/activity_cleanup.gleam
··· 37 37 } 38 38 } 39 39 40 - fn handle_message( 41 - state: State, 42 - message: Message, 43 - ) -> actor.Next(State, Message) { 40 + fn handle_message(state: State, message: Message) -> actor.Next(State, Message) { 44 41 case message { 45 42 Cleanup -> { 46 43 // Clean up activity entries older than 7 days (168 hours) ··· 49 46 Error(err) -> { 50 47 logging.log( 51 48 logging.Error, 52 - "[cleanup] Failed to cleanup old activity: " 53 - <> string.inspect(err), 49 + "[cleanup] Failed to cleanup old activity: " <> string.inspect(err), 54 50 ) 55 51 } 56 52 }
+16 -6
server/src/client_graphql_handler.gleam
··· 43 43 case bit_array.to_string(body) { 44 44 Ok(body_string) -> { 45 45 case extract_query_and_variables_from_json(body_string) { 46 - Ok(#(query, variables)) -> execute_query(req, db, admin_dids, jetstream_subject, query, variables) 46 + Ok(#(query, variables)) -> 47 + execute_query( 48 + req, 49 + db, 50 + admin_dids, 51 + jetstream_subject, 52 + query, 53 + variables, 54 + ) 47 55 Error(err) -> bad_request_response("Invalid JSON: " <> err) 48 56 } 49 57 } ··· 62 70 ) -> wisp.Response { 63 71 let query_params = wisp.get_query(req) 64 72 case list.key_find(query_params, "query") { 65 - Ok(query) -> execute_query(req, db, admin_dids, jetstream_subject, query, option.None) 73 + Ok(query) -> 74 + execute_query(req, db, admin_dids, jetstream_subject, query, option.None) 66 75 Error(_) -> bad_request_response("Missing 'query' parameter") 67 76 } 68 77 } ··· 76 85 variables: option.Option(value.Value), 77 86 ) -> wisp.Response { 78 87 // Build the schema 79 - let graphql_schema = client_schema.build_schema(db, req, admin_dids, jetstream_subject) 88 + let graphql_schema = 89 + client_schema.build_schema(db, req, admin_dids, jetstream_subject) 80 90 81 91 // Create context with variables 82 92 let ctx = case variables { ··· 133 143 value.Enum(e) -> json.string(e) 134 144 value.List(items) -> json.array(items, value_to_json) 135 145 value.Object(fields) -> 136 - json.object(list.map(fields, fn(field) { 137 - #(field.0, value_to_json(field.1)) 138 - })) 146 + json.object( 147 + list.map(fields, fn(field) { #(field.0, value_to_json(field.1)) }), 148 + ) 139 149 } 140 150 } 141 151
+107 -62
server/src/client_schema.gleam
··· 25 25 } 26 26 27 27 /// Convert CurrentSession data to GraphQL value 28 - fn current_session_to_value(did: String, handle: String, is_admin: Bool) -> value.Value { 28 + fn current_session_to_value( 29 + did: String, 30 + handle: String, 31 + is_admin: Bool, 32 + ) -> value.Value { 29 33 value.Object([ 30 34 #("did", value.String(did)), 31 35 #("handle", value.String(handle)), ··· 270 274 } 271 275 }, 272 276 ), 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 277 schema.field( 293 - "id", 294 - schema.non_null(schema.int_type()), 295 - "Entry ID", 278 + "oauthClientId", 279 + schema.string_type(), 280 + "OAuth client ID if registered", 296 281 fn(ctx) { 297 282 case ctx.data { 298 283 Some(value.Object(fields)) -> { 299 - case list.key_find(fields, "id") { 300 - Ok(id) -> Ok(id) 284 + case list.key_find(fields, "oauthClientId") { 285 + Ok(client_id) -> Ok(client_id) 301 286 Error(_) -> Ok(value.Null) 302 287 } 303 288 } ··· 305 290 } 306 291 }, 307 292 ), 293 + ]) 294 + } 295 + 296 + /// ActivityEntry type for individual activity records 297 + pub fn activity_entry_type() -> schema.Type { 298 + schema.object_type("ActivityEntry", "Individual activity log entry", [ 299 + schema.field("id", schema.non_null(schema.int_type()), "Entry ID", fn(ctx) { 300 + case ctx.data { 301 + Some(value.Object(fields)) -> { 302 + case list.key_find(fields, "id") { 303 + Ok(id) -> Ok(id) 304 + Error(_) -> Ok(value.Null) 305 + } 306 + } 307 + _ -> Ok(value.Null) 308 + } 309 + }), 308 310 schema.field( 309 311 "timestamp", 310 312 schema.non_null(schema.string_type()), ··· 353 355 } 354 356 }, 355 357 ), 358 + schema.field("did", schema.non_null(schema.string_type()), "DID", fn(ctx) { 359 + case ctx.data { 360 + Some(value.Object(fields)) -> { 361 + case list.key_find(fields, "did") { 362 + Ok(did) -> Ok(did) 363 + Error(_) -> Ok(value.Null) 364 + } 365 + } 366 + _ -> Ok(value.Null) 367 + } 368 + }), 356 369 schema.field( 357 - "did", 370 + "status", 358 371 schema.non_null(schema.string_type()), 359 - "DID", 372 + "Processing status", 360 373 fn(ctx) { 361 374 case ctx.data { 362 375 Some(value.Object(fields)) -> { 363 - case list.key_find(fields, "did") { 364 - Ok(did) -> Ok(did) 376 + case list.key_find(fields, "status") { 377 + Ok(status) -> Ok(status) 365 378 Error(_) -> Ok(value.Null) 366 379 } 367 380 } ··· 370 383 }, 371 384 ), 372 385 schema.field( 373 - "status", 374 - schema.non_null(schema.string_type()), 375 - "Processing status", 386 + "errorMessage", 387 + schema.string_type(), 388 + "Error message if failed", 376 389 fn(ctx) { 377 390 case ctx.data { 378 391 Some(value.Object(fields)) -> { 379 - case list.key_find(fields, "status") { 380 - Ok(status) -> Ok(status) 392 + case list.key_find(fields, "errorMessage") { 393 + Ok(err_msg) -> Ok(err_msg) 381 394 Error(_) -> Ok(value.Null) 382 395 } 383 396 } ··· 385 398 } 386 399 }, 387 400 ), 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 401 schema.field("eventJson", schema.string_type(), "Raw event JSON", fn(ctx) { 402 402 case ctx.data { 403 403 Some(value.Object(fields)) -> { ··· 522 522 schema.non_null(settings_type()), 523 523 "Get system settings", 524 524 fn(_ctx) { 525 - let domain_authority = case database.get_config(conn, "domain_authority") { 525 + let domain_authority = case 526 + database.get_config(conn, "domain_authority") 527 + { 526 528 Ok(authority) -> authority 527 529 Error(_) -> "" 528 530 } ··· 647 649 // Restart Jetstream consumer to pick up new domain authority 648 650 case jetstream_subject { 649 651 Some(consumer) -> { 650 - logging.log(logging.Info, "[updateDomainAuthority] Restarting Jetstream consumer with new domain authority...") 652 + logging.log( 653 + logging.Info, 654 + "[updateDomainAuthority] Restarting Jetstream consumer with new domain authority...", 655 + ) 651 656 let _ = jetstream_consumer.restart(consumer) 652 657 Nil 653 658 } ··· 655 660 } 656 661 657 662 // Fetch OAuth client ID to return complete Settings 658 - let oauth_client_id = case database.get_oauth_credentials(conn) { 663 + let oauth_client_id = case 664 + database.get_oauth_credentials(conn) 665 + { 659 666 Ok(Some(#(client_id, _secret, _uri))) -> Some(client_id) 660 667 _ -> None 661 668 } ··· 690 697 // Restart Jetstream consumer to pick up newly imported collections 691 698 case jetstream_subject { 692 699 Some(consumer) -> { 693 - logging.log(logging.Info, "[uploadLexicons] Restarting Jetstream consumer with new lexicons...") 700 + logging.log( 701 + logging.Info, 702 + "[uploadLexicons] Restarting Jetstream consumer with new lexicons...", 703 + ) 694 704 case jetstream_consumer.restart(consumer) { 695 705 Ok(_) -> { 696 - logging.log(logging.Info, "[uploadLexicons] Jetstream consumer restarted successfully") 706 + logging.log( 707 + logging.Info, 708 + "[uploadLexicons] Jetstream consumer restarted successfully", 709 + ) 697 710 Ok(value.Boolean(True)) 698 711 } 699 712 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) 713 + logging.log( 714 + logging.Error, 715 + "[uploadLexicons] Failed to restart Jetstream consumer: " 716 + <> err, 717 + ) 718 + Error( 719 + "Lexicons imported but failed to restart Jetstream consumer: " 720 + <> err, 721 + ) 702 722 } 703 723 } 704 724 } 705 725 None -> { 706 - logging.log(logging.Info, "[uploadLexicons] Jetstream consumer not running, skipping restart") 726 + logging.log( 727 + logging.Info, 728 + "[uploadLexicons] Jetstream consumer not running, skipping restart", 729 + ) 707 730 Ok(value.Boolean(True)) 708 731 } 709 732 } ··· 747 770 // Restart Jetstream consumer after reset 748 771 case jetstream_subject { 749 772 Some(consumer) -> { 750 - logging.log(logging.Info, "[resetAll] Restarting Jetstream consumer after reset...") 773 + logging.log( 774 + logging.Info, 775 + "[resetAll] Restarting Jetstream consumer after reset...", 776 + ) 751 777 let _ = jetstream_consumer.restart(consumer) 752 778 Nil 753 779 } ··· 780 806 True -> { 781 807 // Spawn background process to run backfill 782 808 process.spawn_unlinked(fn() { 783 - logging.log(logging.Info, "[triggerBackfill] Starting background backfill...") 809 + logging.log( 810 + logging.Info, 811 + "[triggerBackfill] Starting background backfill...", 812 + ) 784 813 785 814 // Get all record-type collections from database (only backfill records, not queries/procedures) 786 - let collections = case database.get_record_type_lexicons(conn) { 815 + let collections = case 816 + database.get_record_type_lexicons(conn) 817 + { 787 818 Ok(lexicons) -> list.map(lexicons, fn(lex) { lex.id }) 788 819 Error(_) -> [] 789 820 } 790 821 791 822 // Get domain authority to determine external collections 792 - let domain_authority = case database.get_config(conn, "domain_authority") { 823 + let domain_authority = case 824 + database.get_config(conn, "domain_authority") 825 + { 793 826 Ok(authority) -> authority 794 827 Error(_) -> "" 795 828 } ··· 797 830 // Split collections into primary and external 798 831 let #(primary_collections, external_collections) = 799 832 list.partition(collections, fn(collection) { 800 - backfill.nsid_matches_domain_authority(collection, domain_authority) 833 + backfill.nsid_matches_domain_authority( 834 + collection, 835 + domain_authority, 836 + ) 801 837 }) 802 838 803 839 // Run backfill with default config and empty repo list (fetches from relay) 804 840 let config = backfill.default_config() 805 - backfill.backfill_collections([], primary_collections, external_collections, config, conn) 841 + backfill.backfill_collections( 842 + [], 843 + primary_collections, 844 + external_collections, 845 + config, 846 + conn, 847 + ) 806 848 807 - logging.log(logging.Info, "[triggerBackfill] Background backfill completed") 849 + logging.log( 850 + logging.Info, 851 + "[triggerBackfill] Background backfill completed", 852 + ) 808 853 }) 809 854 810 855 // Return immediately
+8 -8
server/src/database.gleam
··· 269 269 270 270 /// Migration v4: Add jetstream_activity table for 24h activity log 271 271 fn migration_v4(conn: sqlight.Connection) -> Result(Nil, sqlight.Error) { 272 - logging.log(logging.Info, "Running migration v4 (jetstream_activity table)...") 272 + logging.log( 273 + logging.Info, 274 + "Running migration v4 (jetstream_activity table)...", 275 + ) 273 276 274 277 let create_table_sql = 275 278 " ··· 543 546 list.map(batch, fn(_) { "?" }) 544 547 |> string.join(", ") 545 548 546 - let sql = 547 - " 549 + let sql = " 548 550 SELECT uri, cid 549 551 FROM record 550 552 WHERE uri IN (" <> placeholders <> ") ··· 598 600 list.map(batch, fn(_) { "?" }) 599 601 |> string.join(", ") 600 602 601 - let sql = 602 - " 603 + let sql = " 603 604 SELECT cid 604 605 FROM record 605 606 WHERE cid IN (" <> placeholders <> ") ··· 710 711 )) 711 712 712 713 // Create a set of existing CIDs for fast lookup 713 - let existing_cid_set = dict.from_list( 714 - list.map(existing_cids_in_db, fn(cid) { #(cid, True) }), 715 - ) 714 + let existing_cid_set = 715 + dict.from_list(list.map(existing_cids_in_db, fn(cid) { #(cid, True) })) 716 716 717 717 // Filter out records where: 718 718 // 1. URI exists with same CID (unchanged)
+48 -41
server/src/event_handler.gleam
··· 223 223 { 224 224 Ok(_) -> 225 225 // Publish activity event for real-time UI updates 226 - stats_pubsub.publish(stats_pubsub.ActivityLogged( 227 - id, 228 - timestamp, 229 - commit.operation, 230 - commit.collection, 231 - did, 232 - "success", 233 - option.None, 234 - event_json, 235 - )) 226 + stats_pubsub.publish( 227 + stats_pubsub.ActivityLogged( 228 + id, 229 + timestamp, 230 + commit.operation, 231 + commit.collection, 232 + did, 233 + "success", 234 + option.None, 235 + event_json, 236 + ), 237 + ) 236 238 Error(_) -> Nil 237 239 } 238 240 } ··· 263 265 264 266 // Publish stats event for real-time stats updates 265 267 case is_create { 266 - True -> stats_pubsub.publish(stats_pubsub.RecordCreated) 268 + True -> 269 + stats_pubsub.publish(stats_pubsub.RecordCreated) 267 270 False -> Nil 268 271 } 269 272 } ··· 291 294 { 292 295 Ok(_) -> 293 296 // Publish activity event for real-time UI updates 294 - stats_pubsub.publish(stats_pubsub.ActivityLogged( 295 - id, 296 - timestamp, 297 - commit.operation, 298 - commit.collection, 299 - did, 300 - "success", 301 - option.Some("Skipped: duplicate CID"), 302 - event_json, 303 - )) 297 + stats_pubsub.publish( 298 + stats_pubsub.ActivityLogged( 299 + id, 300 + timestamp, 301 + commit.operation, 302 + commit.collection, 303 + did, 304 + "success", 305 + option.Some("Skipped: duplicate CID"), 306 + event_json, 307 + ), 308 + ) 304 309 Error(_) -> Nil 305 310 } 306 311 } ··· 333 338 { 334 339 Ok(_) -> { 335 340 let error_msg = 336 - "Database insert failed: " <> string.inspect(err) 341 + "Database insert failed: " 342 + <> string.inspect(err) 337 343 // Publish activity event for real-time UI updates 338 - stats_pubsub.publish(stats_pubsub.ActivityLogged( 339 - id, 340 - timestamp, 341 - commit.operation, 342 - commit.collection, 343 - did, 344 - "error", 345 - option.Some(error_msg), 346 - event_json, 347 - )) 344 + stats_pubsub.publish( 345 + stats_pubsub.ActivityLogged( 346 + id, 347 + timestamp, 348 + commit.operation, 349 + commit.collection, 350 + did, 351 + "error", 352 + option.Some(error_msg), 353 + event_json, 354 + ), 355 + ) 348 356 } 349 357 Error(_) -> Nil 350 358 } ··· 371 379 db, 372 380 id, 373 381 "error", 374 - option.Some("Actor validation failed: " <> actor_err), 382 + option.Some( 383 + "Actor validation failed: " <> actor_err, 384 + ), 375 385 ) 376 386 { 377 387 Ok(_) -> { 378 - let error_msg = "Actor validation failed: " <> actor_err 388 + let error_msg = 389 + "Actor validation failed: " <> actor_err 379 390 // Publish activity event for real-time UI updates 380 391 stats_pubsub.publish(stats_pubsub.ActivityLogged( 381 392 id, ··· 417 428 ) 418 429 { 419 430 Ok(_) -> { 420 - let error_msg = lexicon.describe_error(validation_error) 431 + let error_msg = 432 + lexicon.describe_error(validation_error) 421 433 // Publish activity event for real-time UI updates 422 434 stats_pubsub.publish(stats_pubsub.ActivityLogged( 423 435 id, ··· 507 519 case activity_id { 508 520 option.Some(id) -> { 509 521 case 510 - jetstream_activity.update_status( 511 - db, 512 - id, 513 - "success", 514 - option.None, 515 - ) 522 + jetstream_activity.update_status(db, id, "success", option.None) 516 523 { 517 524 Ok(_) -> 518 525 // Publish activity event for real-time UI updates
+3 -6
server/src/jetstream_activity.gleam
··· 96 96 conn: sqlight.Connection, 97 97 hours: Int, 98 98 ) -> Result(List(ActivityEntry), sqlight.Error) { 99 - let sql = 100 - " 99 + let sql = " 101 100 SELECT id, timestamp, operation, collection, did, status, error_message, event_json 102 101 FROM jetstream_activity 103 102 WHERE datetime(timestamp) >= datetime('now', '-" <> int.to_string(hours) <> " hours') ··· 134 133 conn: sqlight.Connection, 135 134 hours: Int, 136 135 ) -> Result(Nil, sqlight.Error) { 137 - let sql = 138 - " 136 + let sql = " 139 137 DELETE FROM jetstream_activity 140 138 WHERE datetime(timestamp) < datetime('now', '-" <> int.to_string(hours) <> " hours') 141 139 " ··· 253 251 let max_n = int.to_string(expected_buckets) 254 252 255 253 // Build the SQL dynamically based on interval 256 - let sql = 257 - " 254 + let sql = " 258 255 WITH RECURSIVE time_series(bucket, n) AS ( 259 256 SELECT datetime('now', '-" <> hours_str <> " hours'), 0 260 257 UNION ALL
+11 -8
server/src/oauth/session.gleam
··· 222 222 let signed_value = wisp.sign_message(req, <<session_id:utf8>>, crypto.Sha512) 223 223 224 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 232 - ) 225 + let attributes = 226 + cookie.Attributes( 227 + max_age: option.Some(60 * 60 * 24 * 14), 228 + domain: option.None, 229 + path: option.Some("/"), 230 + secure: False, 231 + // False for localhost HTTP 232 + http_only: True, 233 + same_site: option.None, 234 + // No SameSite restriction for JavaScript fetch 235 + ) 233 236 234 237 response.set_cookie(response, session_cookie_name, signed_value, attributes) 235 238 }
+11 -6
server/src/server.gleam
··· 1 1 import activity_cleanup 2 2 import argv 3 3 import backfill 4 - import client_graphql_handler 5 4 import backfill_state 5 + import client_graphql_handler 6 6 import config 7 7 import database 8 8 import dotenv_gleam ··· 550 550 ["logout"] -> handlers.handle_logout(req, ctx.db) 551 551 ["backfill"] -> handle_backfill_request(req, ctx) 552 552 ["admin", "graphql"] -> 553 - client_graphql_handler.handle_client_graphql_request(req, ctx.db, ctx.admin_dids, ctx.jetstream_consumer) 553 + client_graphql_handler.handle_client_graphql_request( 554 + req, 555 + ctx.db, 556 + ctx.admin_dids, 557 + ctx.jetstream_consumer, 558 + ) 554 559 ["graphql"] -> 555 560 graphql_handler.handle_graphql_request( 556 561 req, ··· 740 745 // Determine content type based on file extension 741 746 let content_type = case list.last(path_segments) { 742 747 Ok(filename) -> { 743 - case string.ends_with(filename, ".mjs") || string.ends_with( 744 - filename, 745 - ".js", 746 - ) { 748 + case 749 + string.ends_with(filename, ".mjs") 750 + || string.ends_with(filename, ".js") 751 + { 747 752 True -> "application/javascript" 748 753 False -> 749 754 case string.ends_with(filename, ".css") {