Gleam Lustre Fullstack Atproto Demo App w/Slices.Network GraphQL API

wire up edit profile form

+668 -34
+133 -3
client/src/client.gleam
··· 1 + import gleam/int 1 2 import gleam/io 2 3 import gleam/javascript/promise 3 4 import gleam/json 5 + import gleam/list 4 6 import gleam/option.{None, Some} 5 7 import gleam/result 6 8 import gleam/string ··· 279 281 #(model, effect.none()) 280 282 } 281 283 profile_edit.FormSubmitted -> { 282 - // TODO: Handle form submission 283 - io.println("Profile form submitted") 284 - #(model, effect.none()) 284 + // Clear any existing messages and set saving state 285 + let form_data = 286 + profile_edit.FormData( 287 + ..model.edit_form_data, 288 + is_saving: True, 289 + success_message: None, 290 + error_message: None, 291 + ) 292 + let model = Model(..model, edit_form_data: form_data) 293 + 294 + // Get the handle from the route 295 + case model.route { 296 + ProfileEdit(handle: handle) -> { 297 + #(model, save_profile_effect(handle, model.edit_form_data)) 298 + } 299 + _ -> #(model, effect.none()) 300 + } 301 + } 302 + profile_edit.SaveCompleted(result) -> { 303 + let form_data = case result { 304 + Ok(_) -> 305 + profile_edit.FormData( 306 + ..model.edit_form_data, 307 + is_saving: False, 308 + success_message: Some("Profile updated successfully!"), 309 + error_message: None, 310 + ) 311 + Error(err) -> 312 + profile_edit.FormData( 313 + ..model.edit_form_data, 314 + is_saving: False, 315 + success_message: None, 316 + error_message: Some(err), 317 + ) 318 + } 319 + #(Model(..model, edit_form_data: form_data), effect.none()) 285 320 } 286 321 profile_edit.CancelClicked -> { 287 322 // Navigate back to profile page ··· 338 373 339 374 @external(javascript, "./client_ffi.mjs", "fetchUrl") 340 375 fn fetch_url(url: String) -> promise.Promise(Result(#(Int, String), String)) 376 + 377 + @external(javascript, "./client_ffi.mjs", "postJson") 378 + fn post_json( 379 + url: String, 380 + json_body: String, 381 + ) -> promise.Promise(Result(#(Int, String), String)) 382 + 383 + fn save_profile_effect( 384 + handle: String, 385 + form_data: profile_edit.FormData, 386 + ) -> Effect(Msg) { 387 + effect.from(fn(dispatch) { 388 + let url = "/api/profile/" <> handle <> "/update" 389 + 390 + // Build the JSON body 391 + let json_fields = [] 392 + 393 + // Add display_name if not empty 394 + let json_fields = case form_data.display_name { 395 + "" -> json_fields 396 + name -> [#("display_name", json.string(name)), ..json_fields] 397 + } 398 + 399 + // Add description if not empty 400 + let json_fields = case form_data.description { 401 + "" -> json_fields 402 + desc -> [#("description", json.string(desc)), ..json_fields] 403 + } 404 + 405 + // Add home_town as JSON object with name and h3_index 406 + let json_fields = case form_data.location_input.selected_location { 407 + Some(loc) -> { 408 + let location_json = 409 + json.object([ 410 + #("name", json.string(loc.name)), 411 + #("value", json.string(loc.h3_index)), 412 + ]) 413 + [#("home_town", location_json), ..json_fields] 414 + } 415 + None -> json_fields 416 + } 417 + 418 + // Add interests as array (split by comma) 419 + let json_fields = case form_data.interests { 420 + "" -> json_fields 421 + interests_str -> { 422 + let interests_list = 423 + string.split(interests_str, ",") 424 + |> list.map(string.trim) 425 + |> list.filter(fn(s) { s != "" }) 426 + [#("interests", json.array(interests_list, json.string)), ..json_fields] 427 + } 428 + } 429 + 430 + let json_body = json.object(json_fields) |> json.to_string 431 + 432 + io.println("Sending profile update: " <> json_body) 433 + 434 + post_json(url, json_body) 435 + |> promise.map(fn(result) { 436 + case result { 437 + Ok(#(200, _text)) -> { 438 + io.println("Profile saved successfully") 439 + dispatch( 440 + ProfileEditMsg(profile_edit.SaveCompleted(Ok(Nil))), 441 + ) 442 + } 443 + Ok(#(status, text)) -> { 444 + io.println( 445 + "Save failed with status " 446 + <> int.to_string(status) 447 + <> ": " 448 + <> text, 449 + ) 450 + dispatch( 451 + ProfileEditMsg( 452 + profile_edit.SaveCompleted( 453 + Error("Failed to save profile (status " <> int.to_string(status) <> ")"), 454 + ), 455 + ), 456 + ) 457 + } 458 + Error(err) -> { 459 + io.println("Save request failed: " <> err) 460 + dispatch( 461 + ProfileEditMsg(profile_edit.SaveCompleted(Error(err))), 462 + ) 463 + } 464 + } 465 + }) 466 + |> promise.await(fn(_) { promise.resolve(Nil) }) 467 + 468 + Nil 469 + }) 470 + } 341 471 342 472 // VIEW ------------------------------------------------------------------------ 343 473
+20
client/src/client_ffi.mjs
··· 24 24 }); 25 25 } 26 26 27 + export function postJson(url, jsonString) { 28 + return fetch(url, { 29 + method: "POST", 30 + headers: { 31 + "Content-Type": "application/json", 32 + }, 33 + body: jsonString, 34 + }) 35 + .then((response) => { 36 + return response.text().then((text) => { 37 + // Return Ok(#(status, text)) 38 + return new Ok([response.status, text]); 39 + }); 40 + }) 41 + .catch((error) => { 42 + // Return Error(message) 43 + return new Error(error.message || "Network error"); 44 + }); 45 + } 46 + 27 47 /** 28 48 * Search for locations using the Nominatim OpenStreetMap geocoding API 29 49 * Returns a Promise that resolves to Result(List(NominatimResult), String)
+49 -3
client/src/pages/profile_edit.gleam
··· 18 18 AvatarFileSelected(String) 19 19 LocationInputMsg(location_input.Msg) 20 20 FormSubmitted 21 + SaveCompleted(Result(Nil, String)) 21 22 CancelClicked 22 23 } 23 24 ··· 28 29 location_input: location_input.Model, 29 30 interests: String, 30 31 avatar_preview_url: Option(String), 32 + success_message: Option(String), 33 + error_message: Option(String), 34 + is_saving: Bool, 31 35 ) 32 36 } 33 37 ··· 52 56 location_input: location_input.init(location_data), 53 57 interests: interests_str, 54 58 avatar_preview_url: p.avatar_url, 59 + success_message: option.None, 60 + error_message: option.None, 61 + is_saving: False, 55 62 ) 56 63 } 57 64 option.None -> ··· 61 68 location_input: location_input.init(option.None), 62 69 interests: "", 63 70 avatar_preview_url: option.None, 71 + success_message: option.None, 72 + error_message: option.None, 73 + is_saving: False, 64 74 ) 65 75 } 66 76 } ··· 92 102 html.text("@" <> handle), 93 103 ]), 94 104 ]), 105 + // Success/Error Messages 106 + case form_data.success_message { 107 + option.Some(msg) -> 108 + html.div( 109 + [ 110 + attribute.class( 111 + "p-4 bg-green-900/20 border border-green-800 rounded-lg text-green-300 text-sm", 112 + ), 113 + ], 114 + [html.text(msg)], 115 + ) 116 + option.None -> element.none() 117 + }, 118 + case form_data.error_message { 119 + option.Some(msg) -> 120 + html.div( 121 + [ 122 + attribute.class( 123 + "p-4 bg-red-900/20 border border-red-800 rounded-lg text-red-300 text-sm", 124 + ), 125 + ], 126 + [html.text(msg)], 127 + ) 128 + option.None -> element.none() 129 + }, 95 130 // Form 96 131 html.form( 97 132 [ ··· 204 239 button.Md, 205 240 [html.text("Cancel")], 206 241 ), 207 - button.button([attribute.type_("submit")], button.Primary, button.Md, [ 208 - html.text("Save Changes"), 209 - ]), 242 + button.button( 243 + [ 244 + attribute.type_("submit"), 245 + attribute.disabled(form_data.is_saving), 246 + ], 247 + button.Primary, 248 + button.Md, 249 + [ 250 + html.text(case form_data.is_saving { 251 + True -> "Saving..." 252 + False -> "Save Changes" 253 + }), 254 + ], 255 + ), 210 256 ]), 211 257 ], 212 258 ),
+254 -15
server/priv/static/client.js
··· 989 989 function reverse(list) { 990 990 return reverse_and_prepend(list, toList([])); 991 991 } 992 + function filter_loop(loop$list, loop$fun, loop$acc) { 993 + while (true) { 994 + let list = loop$list; 995 + let fun = loop$fun; 996 + let acc = loop$acc; 997 + if (list instanceof Empty) { 998 + return reverse(acc); 999 + } else { 1000 + let first$1 = list.head; 1001 + let rest$1 = list.tail; 1002 + let _block; 1003 + let $ = fun(first$1); 1004 + if ($) { 1005 + _block = prepend(first$1, acc); 1006 + } else { 1007 + _block = acc; 1008 + } 1009 + let new_acc = _block; 1010 + loop$list = rest$1; 1011 + loop$fun = fun; 1012 + loop$acc = new_acc; 1013 + } 1014 + } 1015 + } 1016 + function filter(list, predicate) { 1017 + return filter_loop(list, predicate, toList([])); 1018 + } 992 1019 function filter_map_loop(loop$list, loop$fun, loop$acc) { 993 1020 while (true) { 994 1021 let list = loop$list; ··· 1471 1498 return join_loop(rest, separator, first$1); 1472 1499 } 1473 1500 } 1501 + function trim(string) { 1502 + let _pipe = string; 1503 + let _pipe$1 = trim_start(_pipe); 1504 + return trim_end(_pipe$1); 1505 + } 1474 1506 function split2(x, substring) { 1475 1507 if (substring === "") { 1476 1508 return graphemes(x); ··· 1794 1826 ].join(""); 1795 1827 var trim_start_regex = /* @__PURE__ */ new RegExp(`^[${unicode_whitespaces}]*`); 1796 1828 var trim_end_regex = /* @__PURE__ */ new RegExp(`[${unicode_whitespaces}]*$`); 1829 + function trim_start(string3) { 1830 + return string3.replace(trim_start_regex, ""); 1831 + } 1832 + function trim_end(string3) { 1833 + return string3.replace(trim_end_regex, ""); 1834 + } 1797 1835 function console_log(term) { 1798 1836 console.log(term); 1799 1837 } ··· 2187 2225 }); 2188 2226 } 2189 2227 // build/dev/javascript/gleam_json/gleam_json_ffi.mjs 2228 + function json_to_string(json) { 2229 + return JSON.stringify(json); 2230 + } 2231 + function object(entries) { 2232 + return Object.fromEntries(entries); 2233 + } 2190 2234 function identity2(x) { 2191 2235 return x; 2236 + } 2237 + function array(list3) { 2238 + return list3.toArray(); 2192 2239 } 2193 2240 function decode(string3) { 2194 2241 try { ··· 2304 2351 function parse(json, decoder) { 2305 2352 return do_parse(json, decoder); 2306 2353 } 2354 + function to_string2(json) { 2355 + return json_to_string(json); 2356 + } 2307 2357 function string3(input) { 2308 2358 return identity2(input); 2309 2359 } 2360 + function bool(input) { 2361 + return identity2(input); 2362 + } 2363 + function object2(entries) { 2364 + return object(entries); 2365 + } 2366 + function preprocessed_array(from) { 2367 + return array(from); 2368 + } 2369 + function array2(entries, inner_type) { 2370 + let _pipe = entries; 2371 + let _pipe$1 = map(_pipe, inner_type); 2372 + return preprocessed_array(_pipe$1); 2373 + } 2310 2374 2311 2375 // build/dev/javascript/gleam_stdlib/gleam/uri.mjs 2312 2376 class Uri extends CustomType { ··· 2359 2423 function path_segments(path) { 2360 2424 return remove_dot_segments(split2(path, "/")); 2361 2425 } 2362 - function to_string2(uri) { 2426 + function to_string3(uri) { 2363 2427 let _block; 2364 2428 let $ = uri.fragment; 2365 2429 if ($ instanceof Some) { ··· 2673 2737 function property2(name, value) { 2674 2738 return property(name, value); 2675 2739 } 2740 + function boolean_attribute(name, value) { 2741 + if (value) { 2742 + return attribute2(name, ""); 2743 + } else { 2744 + return property2(name, bool(false)); 2745 + } 2746 + } 2676 2747 function class$(name) { 2677 2748 return attribute2("class", name); 2678 2749 } ··· 2690 2761 } 2691 2762 function accept(values3) { 2692 2763 return attribute2("accept", join(values3, ",")); 2764 + } 2765 + function disabled(is_disabled) { 2766 + return boolean_attribute("disabled", is_disabled); 2693 2767 } 2694 2768 function placeholder(text) { 2695 2769 return attribute2("placeholder", text); ··· 2860 2934 } 2861 2935 } 2862 2936 } 2863 - function to_string3(path) { 2937 + function to_string4(path) { 2864 2938 return do_to_string(path, toList([])); 2865 2939 } 2866 2940 function matches(path, candidates) { 2867 2941 if (candidates instanceof Empty) { 2868 2942 return false; 2869 2943 } else { 2870 - return do_matches(to_string3(path), candidates); 2944 + return do_matches(to_string4(path), candidates); 2871 2945 } 2872 2946 } 2873 2947 var separator_event = ` ··· 5448 5522 }); 5449 5523 }; 5450 5524 var do_push = (uri) => { 5451 - window.history.pushState({}, "", to_string2(uri)); 5525 + window.history.pushState({}, "", to_string3(uri)); 5452 5526 window.requestAnimationFrame(() => { 5453 5527 if (uri.fragment[0]) { 5454 5528 document.getElementById(uri.fragment[0])?.scrollIntoView(); ··· 19494 19568 return new Error2(error.message || "Network error"); 19495 19569 }); 19496 19570 } 19571 + function postJson(url, jsonString) { 19572 + return fetch(url, { 19573 + method: "POST", 19574 + headers: { 19575 + "Content-Type": "application/json" 19576 + }, 19577 + body: jsonString 19578 + }).then((response) => { 19579 + return response.text().then((text4) => { 19580 + return new Ok([response.status, text4]); 19581 + }); 19582 + }).catch((error) => { 19583 + return new Error2(error.message || "Network error"); 19584 + }); 19585 + } 19497 19586 function searchLocations(query) { 19498 19587 if (!query || query.trim().length < 2) { 19499 19588 return Promise.resolve(new Ok(toList2([]))); ··· 20101 20190 } 20102 20191 class FormSubmitted extends CustomType { 20103 20192 } 20193 + class SaveCompleted extends CustomType { 20194 + constructor($0) { 20195 + super(); 20196 + this[0] = $0; 20197 + } 20198 + } 20104 20199 class CancelClicked extends CustomType { 20105 20200 } 20106 20201 class FormData2 extends CustomType { 20107 - constructor(display_name, description, location_input, interests, avatar_preview_url) { 20202 + constructor(display_name, description, location_input, interests, avatar_preview_url, success_message, error_message, is_saving) { 20108 20203 super(); 20109 20204 this.display_name = display_name; 20110 20205 this.description = description; 20111 20206 this.location_input = location_input; 20112 20207 this.interests = interests; 20113 20208 this.avatar_preview_url = avatar_preview_url; 20209 + this.success_message = success_message; 20210 + this.error_message = error_message; 20211 + this.is_saving = is_saving; 20114 20212 } 20115 20213 } 20116 20214 function init_form_data(profile) { ··· 20133 20231 _block$1 = $1; 20134 20232 } 20135 20233 let location_data = _block$1; 20136 - return new FormData2(unwrap(p2.display_name, ""), unwrap(p2.description, ""), init2(location_data), interests_str, p2.avatar_url); 20234 + return new FormData2(unwrap(p2.display_name, ""), unwrap(p2.description, ""), init2(location_data), interests_str, p2.avatar_url, new None, new None, false); 20137 20235 } else { 20138 - return new FormData2("", "", init2(new None), "", new None); 20236 + return new FormData2("", "", init2(new None), "", new None, new None, new None, false); 20139 20237 } 20140 20238 } 20141 20239 function view5(profile, form_data, handle2, on_msg) { ··· 20148 20246 h2(toList([class$("text-3xl font-bold text-white mb-2")]), toList([text3("Profile Settings")])), 20149 20247 p(toList([class$("text-zinc-500 text-sm")]), toList([text3("@" + handle2)])) 20150 20248 ])), 20249 + (() => { 20250 + let $ = form_data.success_message; 20251 + if ($ instanceof Some) { 20252 + let msg = $[0]; 20253 + return div(toList([ 20254 + class$("p-4 bg-green-900/20 border border-green-800 rounded-lg text-green-300 text-sm") 20255 + ]), toList([text3(msg)])); 20256 + } else { 20257 + return none2(); 20258 + } 20259 + })(), 20260 + (() => { 20261 + let $ = form_data.error_message; 20262 + if ($ instanceof Some) { 20263 + let msg = $[0]; 20264 + return div(toList([ 20265 + class$("p-4 bg-red-900/20 border border-red-800 rounded-lg text-red-300 text-sm") 20266 + ]), toList([text3(msg)])); 20267 + } else { 20268 + return none2(); 20269 + } 20270 + })(), 20151 20271 form(toList([ 20152 20272 class$("space-y-6"), 20153 20273 on_submit((_) => { ··· 20227 20347 type_("button"), 20228 20348 on_click(on_msg(new CancelClicked)) 20229 20349 ]), new Default, new Md2, toList([text3("Cancel")])), 20230 - button2(toList([type_("submit")]), new Primary, new Md2, toList([text3("Save Changes")])) 20350 + button2(toList([ 20351 + type_("submit"), 20352 + disabled(form_data.is_saving) 20353 + ]), new Primary, new Md2, toList([ 20354 + text3((() => { 20355 + let $ = form_data.is_saving; 20356 + if ($) { 20357 + return "Saving..."; 20358 + } else { 20359 + return "Save Changes"; 20360 + } 20361 + })()) 20362 + ])) 20231 20363 ])) 20232 20364 ])) 20233 20365 ])); ··· 20508 20640 return; 20509 20641 }); 20510 20642 } 20643 + function save_profile_effect(handle2, form_data) { 20644 + return from((dispatch) => { 20645 + let url = "/api/profile/" + handle2 + "/update"; 20646 + let json_fields = toList([]); 20647 + let _block; 20648 + let $ = form_data.display_name; 20649 + if ($ === "") { 20650 + _block = json_fields; 20651 + } else { 20652 + let name = $; 20653 + _block = prepend(["display_name", string3(name)], json_fields); 20654 + } 20655 + let json_fields$1 = _block; 20656 + let _block$1; 20657 + let $1 = form_data.description; 20658 + if ($1 === "") { 20659 + _block$1 = json_fields$1; 20660 + } else { 20661 + let desc = $1; 20662 + _block$1 = prepend(["description", string3(desc)], json_fields$1); 20663 + } 20664 + let json_fields$2 = _block$1; 20665 + let _block$2; 20666 + let $2 = form_data.location_input.selected_location; 20667 + if ($2 instanceof Some) { 20668 + let loc = $2[0]; 20669 + let location_json = object2(toList([ 20670 + ["name", string3(loc.name)], 20671 + ["value", string3(loc.h3_index)] 20672 + ])); 20673 + _block$2 = prepend(["home_town", location_json], json_fields$2); 20674 + } else { 20675 + _block$2 = json_fields$2; 20676 + } 20677 + let json_fields$3 = _block$2; 20678 + let _block$3; 20679 + let $3 = form_data.interests; 20680 + if ($3 === "") { 20681 + _block$3 = json_fields$3; 20682 + } else { 20683 + let interests_str = $3; 20684 + let _block$42; 20685 + let _pipe2 = split2(interests_str, ","); 20686 + let _pipe$12 = map(_pipe2, trim); 20687 + _block$42 = filter(_pipe$12, (s) => { 20688 + return s !== ""; 20689 + }); 20690 + let interests_list = _block$42; 20691 + _block$3 = prepend(["interests", array2(interests_list, string3)], json_fields$3); 20692 + } 20693 + let json_fields$4 = _block$3; 20694 + let _block$4; 20695 + let _pipe = object2(json_fields$4); 20696 + _block$4 = to_string2(_pipe); 20697 + let json_body = _block$4; 20698 + console_log("Sending profile update: " + json_body); 20699 + let _pipe$1 = postJson(url, json_body); 20700 + let _pipe$2 = map_promise(_pipe$1, (result) => { 20701 + if (result instanceof Ok) { 20702 + let $4 = result[0][0]; 20703 + if ($4 === 200) { 20704 + console_log("Profile saved successfully"); 20705 + return dispatch(new ProfileEditMsg(new SaveCompleted(new Ok(undefined)))); 20706 + } else { 20707 + let status = $4; 20708 + let text4 = result[0][1]; 20709 + console_log("Save failed with status " + to_string(status) + ": " + text4); 20710 + return dispatch(new ProfileEditMsg(new SaveCompleted(new Error2("Failed to save profile (status " + to_string(status) + ")")))); 20711 + } 20712 + } else { 20713 + let err = result[0]; 20714 + console_log("Save request failed: " + err); 20715 + return dispatch(new ProfileEditMsg(new SaveCompleted(new Error2(err)))); 20716 + } 20717 + }); 20718 + then_await(_pipe$2, (_) => { 20719 + return resolve(undefined); 20720 + }); 20721 + return; 20722 + }); 20723 + } 20511 20724 function update3(model, msg) { 20512 20725 if (msg instanceof UserNavigatedTo) { 20513 20726 let route = msg.route; ··· 20601 20814 let value3 = edit_msg[0]; 20602 20815 let _block; 20603 20816 let _record = model.edit_form_data; 20604 - _block = new FormData2(value3, _record.description, _record.location_input, _record.interests, _record.avatar_preview_url); 20817 + _block = new FormData2(value3, _record.description, _record.location_input, _record.interests, _record.avatar_preview_url, _record.success_message, _record.error_message, _record.is_saving); 20605 20818 let form_data = _block; 20606 20819 return [ 20607 20820 new Model2(model.route, model.profile_state, form_data), ··· 20611 20824 let value3 = edit_msg[0]; 20612 20825 let _block; 20613 20826 let _record = model.edit_form_data; 20614 - _block = new FormData2(_record.display_name, value3, _record.location_input, _record.interests, _record.avatar_preview_url); 20827 + _block = new FormData2(_record.display_name, value3, _record.location_input, _record.interests, _record.avatar_preview_url, _record.success_message, _record.error_message, _record.is_saving); 20615 20828 let form_data = _block; 20616 20829 return [ 20617 20830 new Model2(model.route, model.profile_state, form_data), ··· 20621 20834 let value3 = edit_msg[0]; 20622 20835 let _block; 20623 20836 let _record = model.edit_form_data; 20624 - _block = new FormData2(_record.display_name, _record.description, _record.location_input, value3, _record.avatar_preview_url); 20837 + _block = new FormData2(_record.display_name, _record.description, _record.location_input, value3, _record.avatar_preview_url, _record.success_message, _record.error_message, _record.is_saving); 20625 20838 let form_data = _block; 20626 20839 return [ 20627 20840 new Model2(model.route, model.profile_state, form_data), ··· 20638 20851 location_effect = $[1]; 20639 20852 let _block; 20640 20853 let _record = model.edit_form_data; 20641 - _block = new FormData2(_record.display_name, _record.description, location_model, _record.interests, _record.avatar_preview_url); 20854 + _block = new FormData2(_record.display_name, _record.description, location_model, _record.interests, _record.avatar_preview_url, _record.success_message, _record.error_message, _record.is_saving); 20642 20855 let form_data = _block; 20643 20856 return [ 20644 20857 new Model2(model.route, model.profile_state, form_data), ··· 20650 20863 })() 20651 20864 ]; 20652 20865 } else if (edit_msg instanceof FormSubmitted) { 20653 - console_log("Profile form submitted"); 20654 - return [model, none()]; 20866 + let _block; 20867 + let _record = model.edit_form_data; 20868 + _block = new FormData2(_record.display_name, _record.description, _record.location_input, _record.interests, _record.avatar_preview_url, new None, new None, true); 20869 + let form_data = _block; 20870 + let model$1 = new Model2(model.route, model.profile_state, form_data); 20871 + let $ = model$1.route; 20872 + if ($ instanceof ProfileEdit) { 20873 + let handle2 = $.handle; 20874 + return [model$1, save_profile_effect(handle2, model$1.edit_form_data)]; 20875 + } else { 20876 + return [model$1, none()]; 20877 + } 20878 + } else if (edit_msg instanceof SaveCompleted) { 20879 + let result = edit_msg[0]; 20880 + let _block; 20881 + if (result instanceof Ok) { 20882 + let _record = model.edit_form_data; 20883 + _block = new FormData2(_record.display_name, _record.description, _record.location_input, _record.interests, _record.avatar_preview_url, new Some("Profile updated successfully!"), new None, false); 20884 + } else { 20885 + let err = result[0]; 20886 + let _record = model.edit_form_data; 20887 + _block = new FormData2(_record.display_name, _record.description, _record.location_input, _record.interests, _record.avatar_preview_url, new None, new Some(err), false); 20888 + } 20889 + let form_data = _block; 20890 + return [ 20891 + new Model2(model.route, model.profile_state, form_data), 20892 + none() 20893 + ]; 20655 20894 } else { 20656 20895 let $ = model.route; 20657 20896 if ($ instanceof ProfileEdit) { ··· 20736 20975 let app = application(init3, update3, view6); 20737 20976 let $ = start3(app, "#app", undefined); 20738 20977 if (!($ instanceof Ok)) { 20739 - throw makeError("let_assert", FILEPATH, "client", 25, "main", "Pattern match failed, no pattern matched the value.", { value: $, start: 626, end: 675, pattern_start: 637, pattern_end: 642 }); 20978 + throw makeError("let_assert", FILEPATH, "client", 27, "main", "Pattern match failed, no pattern matched the value.", { value: $, start: 661, end: 710, pattern_start: 672, pattern_end: 677 }); 20740 20979 } 20741 20980 return; 20742 20981 }
+103 -11
server/src/api/graphql.gleam
··· 9 9 import shared/profile.{type Profile} 10 10 11 11 pub type Config { 12 - Config( 13 - api_url: String, 14 - slice_uri: String, 15 - access_token: String, 16 - ) 12 + Config(api_url: String, slice_uri: String, access_token: String) 17 13 } 18 14 19 15 /// Fetch profile by handle from the GraphQL API ··· 75 71 // Check status code 76 72 case resp.status { 77 73 200 -> parse_profile_response(resp.body) 78 - _ -> Error("API returned status " <> string.inspect(resp.status) <> " with body: " <> resp.body) 74 + _ -> 75 + Error( 76 + "API returned status " 77 + <> string.inspect(resp.status) 78 + <> " with body: " 79 + <> resp.body, 80 + ) 79 81 } 80 82 } 81 83 82 84 /// Parse the GraphQL response and extract profile data 83 - fn parse_profile_response(response_body: String) -> Result(Option(Profile), String) { 85 + fn parse_profile_response( 86 + response_body: String, 87 + ) -> Result(Option(Profile), String) { 84 88 // Parse JSON 85 89 use data <- result.try( 86 90 json.parse(response_body, decode.dynamic) ··· 146 150 let avatar_url = case 147 151 decode.run( 148 152 first_edge, 149 - decode.at( 150 - ["node", "avatar", "url"], 151 - decode.optional(decode.string), 152 - ), 153 + decode.at(["node", "avatar", "url"], decode.optional(decode.string)), 153 154 ) 154 155 { 155 156 Ok(val) -> val ··· 208 209 } 209 210 } 210 211 } 212 + 213 + pub type ProfileUpdate { 214 + ProfileUpdate( 215 + display_name: Option(String), 216 + description: Option(String), 217 + home_town: Option(json.Json), 218 + interests: Option(List(String)), 219 + ) 220 + } 221 + 222 + /// Update profile via GraphQL mutation 223 + pub fn update_profile( 224 + config: Config, 225 + _handle: String, 226 + update: ProfileUpdate, 227 + ) -> Result(Nil, String) { 228 + let mutation = 229 + " 230 + mutation UpdateProfile($rkey: String!, $input: OrgAtmosphereconfProfileInput!) { 231 + updateOrgAtmosphereconfProfile(rkey: $rkey, input: $input) { 232 + id 233 + } 234 + } 235 + " 236 + 237 + // Build input object 238 + let input_fields = [] 239 + 240 + let input_fields = case update.display_name { 241 + Some(val) -> [#("displayName", json.string(val)), ..input_fields] 242 + None -> input_fields 243 + } 244 + 245 + let input_fields = case update.description { 246 + Some(val) -> [#("description", json.string(val)), ..input_fields] 247 + None -> input_fields 248 + } 249 + 250 + let input_fields = case update.home_town { 251 + Some(val) -> [#("homeTown", val), ..input_fields] 252 + None -> input_fields 253 + } 254 + 255 + let input_fields = case update.interests { 256 + Some(val) -> [#("interests", json.array(val, json.string)), ..input_fields] 257 + None -> input_fields 258 + } 259 + 260 + let variables = 261 + json.object([ 262 + #("rkey", json.string("self")), 263 + #("input", json.object(input_fields)), 264 + ]) 265 + 266 + let body_json = 267 + json.object([ 268 + #("query", json.string(mutation)), 269 + #("variables", variables), 270 + ]) 271 + 272 + // Build the HTTP request 273 + use req <- result.try( 274 + request.to(config.api_url) 275 + |> result.map_error(fn(_) { "Failed to create request" }), 276 + ) 277 + 278 + let req = 279 + request.set_method(req, http.Post) 280 + |> request.set_header("content-type", "application/json") 281 + |> request.set_header("X-Slice-Uri", config.slice_uri) 282 + |> request.set_header("Authorization", "Bearer " <> config.access_token) 283 + |> request.set_body(json.to_string(body_json)) 284 + 285 + // Send the request 286 + use resp <- result.try( 287 + httpc.send(req) 288 + |> result.map_error(fn(_) { "HTTP request failed" }), 289 + ) 290 + 291 + // Check status code 292 + case resp.status { 293 + 200 -> Ok(Nil) 294 + _ -> 295 + Error( 296 + "API returned status " 297 + <> string.inspect(resp.status) 298 + <> " with body: " 299 + <> resp.body, 300 + ) 301 + } 302 + }
+109 -2
server/src/server.gleam
··· 1 1 import api/graphql 2 + import gleam/dynamic/decode 2 3 import gleam/erlang/process 3 - import gleam/http.{Get} 4 + import gleam/http.{Get, Post} 4 5 import gleam/json 5 - import gleam/option.{type Option} 6 + import gleam/option.{type Option, None, Some} 7 + import gleam/result 6 8 import gleam/string_tree 7 9 import lustre/attribute 8 10 import lustre/element ··· 51 53 case req.method, wisp.path_segments(req) { 52 54 // API endpoint to fetch profile data as JSON 53 55 Get, ["api", "profile", handle] -> fetch_profile_json(handle) 56 + 57 + // API endpoint to update profile 58 + Post, ["api", "profile", handle, "update"] -> update_profile_json(req, handle) 54 59 55 60 // Profile routes - prerender with data 56 61 Get, ["profile", handle] -> serve_profile(handle) ··· 148 153 149 154 serve_index(profile_data) 150 155 } 156 + 157 + fn update_profile_json(req: Request, handle: String) -> Response { 158 + let config = get_graphql_config() 159 + 160 + wisp.log_info("API: Updating profile for handle: " <> handle) 161 + 162 + // Parse request body 163 + use body <- wisp.require_string_body(req) 164 + 165 + // Decode JSON 166 + let update_result = { 167 + use parsed <- result.try( 168 + json.parse(body, decode.dynamic) 169 + |> result.map_error(fn(_) { "Invalid JSON" }), 170 + ) 171 + 172 + // Extract fields from JSON 173 + let display_name = case 174 + decode.run(parsed, decode.at(["display_name"], decode.optional(decode.string))) 175 + { 176 + Ok(val) -> val 177 + Error(_) -> None 178 + } 179 + 180 + let description = case 181 + decode.run(parsed, decode.at(["description"], decode.optional(decode.string))) 182 + { 183 + Ok(val) -> val 184 + Error(_) -> None 185 + } 186 + 187 + // Decode home_town as an object with name and value fields 188 + let home_town = case 189 + decode.run( 190 + parsed, 191 + decode.at( 192 + ["home_town"], 193 + decode.optional({ 194 + use name <- decode.field("name", decode.string) 195 + use value <- decode.field("value", decode.string) 196 + decode.success(#(name, value)) 197 + }), 198 + ), 199 + ) 200 + { 201 + Ok(Some(#(name, value))) -> { 202 + // Re-encode as JSON object 203 + let json_obj = json.object([#("name", json.string(name)), #("value", json.string(value))]) 204 + Some(json_obj) 205 + } 206 + _ -> None 207 + } 208 + 209 + let interests = case 210 + decode.run( 211 + parsed, 212 + decode.at(["interests"], decode.optional(decode.list(decode.string))), 213 + ) 214 + { 215 + Ok(val) -> val 216 + Error(_) -> None 217 + } 218 + 219 + Ok(graphql.ProfileUpdate( 220 + display_name: display_name, 221 + description: description, 222 + home_town: home_town, 223 + interests: interests, 224 + )) 225 + } 226 + 227 + case update_result { 228 + Ok(update) -> { 229 + case graphql.update_profile(config, handle, update) { 230 + Ok(_) -> { 231 + wisp.log_info("API: Profile updated successfully for: " <> handle) 232 + wisp.json_response( 233 + json.to_string(json.object([ 234 + #("success", json.bool(True)), 235 + #("message", json.string("Profile updated successfully")), 236 + ])), 237 + 200, 238 + ) 239 + } 240 + Error(err) -> { 241 + wisp.log_error("API: Failed to update profile: " <> err) 242 + wisp.json_response( 243 + json.to_string(json.object([#("error", json.string(err))])), 244 + 500, 245 + ) 246 + } 247 + } 248 + } 249 + Error(err) -> { 250 + wisp.log_error("API: Failed to parse update request: " <> err) 251 + wisp.json_response( 252 + json.to_string(json.object([#("error", json.string(err))])), 253 + 400, 254 + ) 255 + } 256 + } 257 + }