tangled
alpha
login
or
join now
chadtmiller.com
/
conf-demo
2
fork
atom
Gleam Lustre Fullstack Atproto Demo App w/Slices.Network GraphQL API
2
fork
atom
overview
issues
pulls
pipelines
wire up edit profile form
chadtmiller.com
5 months ago
aef89b41
7b81c53f
+668
-34
6 changed files
expand all
collapse all
unified
split
client
src
client.gleam
client_ffi.mjs
pages
profile_edit.gleam
server
priv
static
client.js
src
api
graphql.gleam
server.gleam
+133
-3
client/src/client.gleam
···
1
1
+
import gleam/int
1
2
import gleam/io
2
3
import gleam/javascript/promise
3
4
import gleam/json
5
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
282
-
// TODO: Handle form submission
283
283
-
io.println("Profile form submitted")
284
284
-
#(model, effect.none())
284
284
+
// Clear any existing messages and set saving state
285
285
+
let form_data =
286
286
+
profile_edit.FormData(
287
287
+
..model.edit_form_data,
288
288
+
is_saving: True,
289
289
+
success_message: None,
290
290
+
error_message: None,
291
291
+
)
292
292
+
let model = Model(..model, edit_form_data: form_data)
293
293
+
294
294
+
// Get the handle from the route
295
295
+
case model.route {
296
296
+
ProfileEdit(handle: handle) -> {
297
297
+
#(model, save_profile_effect(handle, model.edit_form_data))
298
298
+
}
299
299
+
_ -> #(model, effect.none())
300
300
+
}
301
301
+
}
302
302
+
profile_edit.SaveCompleted(result) -> {
303
303
+
let form_data = case result {
304
304
+
Ok(_) ->
305
305
+
profile_edit.FormData(
306
306
+
..model.edit_form_data,
307
307
+
is_saving: False,
308
308
+
success_message: Some("Profile updated successfully!"),
309
309
+
error_message: None,
310
310
+
)
311
311
+
Error(err) ->
312
312
+
profile_edit.FormData(
313
313
+
..model.edit_form_data,
314
314
+
is_saving: False,
315
315
+
success_message: None,
316
316
+
error_message: Some(err),
317
317
+
)
318
318
+
}
319
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
376
+
377
377
+
@external(javascript, "./client_ffi.mjs", "postJson")
378
378
+
fn post_json(
379
379
+
url: String,
380
380
+
json_body: String,
381
381
+
) -> promise.Promise(Result(#(Int, String), String))
382
382
+
383
383
+
fn save_profile_effect(
384
384
+
handle: String,
385
385
+
form_data: profile_edit.FormData,
386
386
+
) -> Effect(Msg) {
387
387
+
effect.from(fn(dispatch) {
388
388
+
let url = "/api/profile/" <> handle <> "/update"
389
389
+
390
390
+
// Build the JSON body
391
391
+
let json_fields = []
392
392
+
393
393
+
// Add display_name if not empty
394
394
+
let json_fields = case form_data.display_name {
395
395
+
"" -> json_fields
396
396
+
name -> [#("display_name", json.string(name)), ..json_fields]
397
397
+
}
398
398
+
399
399
+
// Add description if not empty
400
400
+
let json_fields = case form_data.description {
401
401
+
"" -> json_fields
402
402
+
desc -> [#("description", json.string(desc)), ..json_fields]
403
403
+
}
404
404
+
405
405
+
// Add home_town as JSON object with name and h3_index
406
406
+
let json_fields = case form_data.location_input.selected_location {
407
407
+
Some(loc) -> {
408
408
+
let location_json =
409
409
+
json.object([
410
410
+
#("name", json.string(loc.name)),
411
411
+
#("value", json.string(loc.h3_index)),
412
412
+
])
413
413
+
[#("home_town", location_json), ..json_fields]
414
414
+
}
415
415
+
None -> json_fields
416
416
+
}
417
417
+
418
418
+
// Add interests as array (split by comma)
419
419
+
let json_fields = case form_data.interests {
420
420
+
"" -> json_fields
421
421
+
interests_str -> {
422
422
+
let interests_list =
423
423
+
string.split(interests_str, ",")
424
424
+
|> list.map(string.trim)
425
425
+
|> list.filter(fn(s) { s != "" })
426
426
+
[#("interests", json.array(interests_list, json.string)), ..json_fields]
427
427
+
}
428
428
+
}
429
429
+
430
430
+
let json_body = json.object(json_fields) |> json.to_string
431
431
+
432
432
+
io.println("Sending profile update: " <> json_body)
433
433
+
434
434
+
post_json(url, json_body)
435
435
+
|> promise.map(fn(result) {
436
436
+
case result {
437
437
+
Ok(#(200, _text)) -> {
438
438
+
io.println("Profile saved successfully")
439
439
+
dispatch(
440
440
+
ProfileEditMsg(profile_edit.SaveCompleted(Ok(Nil))),
441
441
+
)
442
442
+
}
443
443
+
Ok(#(status, text)) -> {
444
444
+
io.println(
445
445
+
"Save failed with status "
446
446
+
<> int.to_string(status)
447
447
+
<> ": "
448
448
+
<> text,
449
449
+
)
450
450
+
dispatch(
451
451
+
ProfileEditMsg(
452
452
+
profile_edit.SaveCompleted(
453
453
+
Error("Failed to save profile (status " <> int.to_string(status) <> ")"),
454
454
+
),
455
455
+
),
456
456
+
)
457
457
+
}
458
458
+
Error(err) -> {
459
459
+
io.println("Save request failed: " <> err)
460
460
+
dispatch(
461
461
+
ProfileEditMsg(profile_edit.SaveCompleted(Error(err))),
462
462
+
)
463
463
+
}
464
464
+
}
465
465
+
})
466
466
+
|> promise.await(fn(_) { promise.resolve(Nil) })
467
467
+
468
468
+
Nil
469
469
+
})
470
470
+
}
341
471
342
472
// VIEW ------------------------------------------------------------------------
343
473
+20
client/src/client_ffi.mjs
···
24
24
});
25
25
}
26
26
27
27
+
export function postJson(url, jsonString) {
28
28
+
return fetch(url, {
29
29
+
method: "POST",
30
30
+
headers: {
31
31
+
"Content-Type": "application/json",
32
32
+
},
33
33
+
body: jsonString,
34
34
+
})
35
35
+
.then((response) => {
36
36
+
return response.text().then((text) => {
37
37
+
// Return Ok(#(status, text))
38
38
+
return new Ok([response.status, text]);
39
39
+
});
40
40
+
})
41
41
+
.catch((error) => {
42
42
+
// Return Error(message)
43
43
+
return new Error(error.message || "Network error");
44
44
+
});
45
45
+
}
46
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
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
32
+
success_message: Option(String),
33
33
+
error_message: Option(String),
34
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
59
+
success_message: option.None,
60
60
+
error_message: option.None,
61
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
71
+
success_message: option.None,
72
72
+
error_message: option.None,
73
73
+
is_saving: False,
64
74
)
65
75
}
66
76
}
···
92
102
html.text("@" <> handle),
93
103
]),
94
104
]),
105
105
+
// Success/Error Messages
106
106
+
case form_data.success_message {
107
107
+
option.Some(msg) ->
108
108
+
html.div(
109
109
+
[
110
110
+
attribute.class(
111
111
+
"p-4 bg-green-900/20 border border-green-800 rounded-lg text-green-300 text-sm",
112
112
+
),
113
113
+
],
114
114
+
[html.text(msg)],
115
115
+
)
116
116
+
option.None -> element.none()
117
117
+
},
118
118
+
case form_data.error_message {
119
119
+
option.Some(msg) ->
120
120
+
html.div(
121
121
+
[
122
122
+
attribute.class(
123
123
+
"p-4 bg-red-900/20 border border-red-800 rounded-lg text-red-300 text-sm",
124
124
+
),
125
125
+
],
126
126
+
[html.text(msg)],
127
127
+
)
128
128
+
option.None -> element.none()
129
129
+
},
95
130
// Form
96
131
html.form(
97
132
[
···
204
239
button.Md,
205
240
[html.text("Cancel")],
206
241
),
207
207
-
button.button([attribute.type_("submit")], button.Primary, button.Md, [
208
208
-
html.text("Save Changes"),
209
209
-
]),
242
242
+
button.button(
243
243
+
[
244
244
+
attribute.type_("submit"),
245
245
+
attribute.disabled(form_data.is_saving),
246
246
+
],
247
247
+
button.Primary,
248
248
+
button.Md,
249
249
+
[
250
250
+
html.text(case form_data.is_saving {
251
251
+
True -> "Saving..."
252
252
+
False -> "Save Changes"
253
253
+
}),
254
254
+
],
255
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
992
+
function filter_loop(loop$list, loop$fun, loop$acc) {
993
993
+
while (true) {
994
994
+
let list = loop$list;
995
995
+
let fun = loop$fun;
996
996
+
let acc = loop$acc;
997
997
+
if (list instanceof Empty) {
998
998
+
return reverse(acc);
999
999
+
} else {
1000
1000
+
let first$1 = list.head;
1001
1001
+
let rest$1 = list.tail;
1002
1002
+
let _block;
1003
1003
+
let $ = fun(first$1);
1004
1004
+
if ($) {
1005
1005
+
_block = prepend(first$1, acc);
1006
1006
+
} else {
1007
1007
+
_block = acc;
1008
1008
+
}
1009
1009
+
let new_acc = _block;
1010
1010
+
loop$list = rest$1;
1011
1011
+
loop$fun = fun;
1012
1012
+
loop$acc = new_acc;
1013
1013
+
}
1014
1014
+
}
1015
1015
+
}
1016
1016
+
function filter(list, predicate) {
1017
1017
+
return filter_loop(list, predicate, toList([]));
1018
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
1501
+
function trim(string) {
1502
1502
+
let _pipe = string;
1503
1503
+
let _pipe$1 = trim_start(_pipe);
1504
1504
+
return trim_end(_pipe$1);
1505
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
1829
+
function trim_start(string3) {
1830
1830
+
return string3.replace(trim_start_regex, "");
1831
1831
+
}
1832
1832
+
function trim_end(string3) {
1833
1833
+
return string3.replace(trim_end_regex, "");
1834
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
2228
+
function json_to_string(json) {
2229
2229
+
return JSON.stringify(json);
2230
2230
+
}
2231
2231
+
function object(entries) {
2232
2232
+
return Object.fromEntries(entries);
2233
2233
+
}
2190
2234
function identity2(x) {
2191
2235
return x;
2236
2236
+
}
2237
2237
+
function array(list3) {
2238
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
2354
+
function to_string2(json) {
2355
2355
+
return json_to_string(json);
2356
2356
+
}
2307
2357
function string3(input) {
2308
2358
return identity2(input);
2309
2359
}
2360
2360
+
function bool(input) {
2361
2361
+
return identity2(input);
2362
2362
+
}
2363
2363
+
function object2(entries) {
2364
2364
+
return object(entries);
2365
2365
+
}
2366
2366
+
function preprocessed_array(from) {
2367
2367
+
return array(from);
2368
2368
+
}
2369
2369
+
function array2(entries, inner_type) {
2370
2370
+
let _pipe = entries;
2371
2371
+
let _pipe$1 = map(_pipe, inner_type);
2372
2372
+
return preprocessed_array(_pipe$1);
2373
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
2362
-
function to_string2(uri) {
2426
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
2740
+
function boolean_attribute(name, value) {
2741
2741
+
if (value) {
2742
2742
+
return attribute2(name, "");
2743
2743
+
} else {
2744
2744
+
return property2(name, bool(false));
2745
2745
+
}
2746
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
2764
+
}
2765
2765
+
function disabled(is_disabled) {
2766
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
2863
-
function to_string3(path) {
2937
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
2870
-
return do_matches(to_string3(path), candidates);
2944
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
5451
-
window.history.pushState({}, "", to_string2(uri));
5525
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
19571
+
function postJson(url, jsonString) {
19572
19572
+
return fetch(url, {
19573
19573
+
method: "POST",
19574
19574
+
headers: {
19575
19575
+
"Content-Type": "application/json"
19576
19576
+
},
19577
19577
+
body: jsonString
19578
19578
+
}).then((response) => {
19579
19579
+
return response.text().then((text4) => {
19580
19580
+
return new Ok([response.status, text4]);
19581
19581
+
});
19582
19582
+
}).catch((error) => {
19583
19583
+
return new Error2(error.message || "Network error");
19584
19584
+
});
19585
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
20193
+
class SaveCompleted extends CustomType {
20194
20194
+
constructor($0) {
20195
20195
+
super();
20196
20196
+
this[0] = $0;
20197
20197
+
}
20198
20198
+
}
20104
20199
class CancelClicked extends CustomType {
20105
20200
}
20106
20201
class FormData2 extends CustomType {
20107
20107
-
constructor(display_name, description, location_input, interests, avatar_preview_url) {
20202
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
20209
+
this.success_message = success_message;
20210
20210
+
this.error_message = error_message;
20211
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
20136
-
return new FormData2(unwrap(p2.display_name, ""), unwrap(p2.description, ""), init2(location_data), interests_str, p2.avatar_url);
20234
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
20138
-
return new FormData2("", "", init2(new None), "", new None);
20236
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
20249
+
(() => {
20250
20250
+
let $ = form_data.success_message;
20251
20251
+
if ($ instanceof Some) {
20252
20252
+
let msg = $[0];
20253
20253
+
return div(toList([
20254
20254
+
class$("p-4 bg-green-900/20 border border-green-800 rounded-lg text-green-300 text-sm")
20255
20255
+
]), toList([text3(msg)]));
20256
20256
+
} else {
20257
20257
+
return none2();
20258
20258
+
}
20259
20259
+
})(),
20260
20260
+
(() => {
20261
20261
+
let $ = form_data.error_message;
20262
20262
+
if ($ instanceof Some) {
20263
20263
+
let msg = $[0];
20264
20264
+
return div(toList([
20265
20265
+
class$("p-4 bg-red-900/20 border border-red-800 rounded-lg text-red-300 text-sm")
20266
20266
+
]), toList([text3(msg)]));
20267
20267
+
} else {
20268
20268
+
return none2();
20269
20269
+
}
20270
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
20230
-
button2(toList([type_("submit")]), new Primary, new Md2, toList([text3("Save Changes")]))
20350
20350
+
button2(toList([
20351
20351
+
type_("submit"),
20352
20352
+
disabled(form_data.is_saving)
20353
20353
+
]), new Primary, new Md2, toList([
20354
20354
+
text3((() => {
20355
20355
+
let $ = form_data.is_saving;
20356
20356
+
if ($) {
20357
20357
+
return "Saving...";
20358
20358
+
} else {
20359
20359
+
return "Save Changes";
20360
20360
+
}
20361
20361
+
})())
20362
20362
+
]))
20231
20363
]))
20232
20364
]))
20233
20365
]));
···
20508
20640
return;
20509
20641
});
20510
20642
}
20643
20643
+
function save_profile_effect(handle2, form_data) {
20644
20644
+
return from((dispatch) => {
20645
20645
+
let url = "/api/profile/" + handle2 + "/update";
20646
20646
+
let json_fields = toList([]);
20647
20647
+
let _block;
20648
20648
+
let $ = form_data.display_name;
20649
20649
+
if ($ === "") {
20650
20650
+
_block = json_fields;
20651
20651
+
} else {
20652
20652
+
let name = $;
20653
20653
+
_block = prepend(["display_name", string3(name)], json_fields);
20654
20654
+
}
20655
20655
+
let json_fields$1 = _block;
20656
20656
+
let _block$1;
20657
20657
+
let $1 = form_data.description;
20658
20658
+
if ($1 === "") {
20659
20659
+
_block$1 = json_fields$1;
20660
20660
+
} else {
20661
20661
+
let desc = $1;
20662
20662
+
_block$1 = prepend(["description", string3(desc)], json_fields$1);
20663
20663
+
}
20664
20664
+
let json_fields$2 = _block$1;
20665
20665
+
let _block$2;
20666
20666
+
let $2 = form_data.location_input.selected_location;
20667
20667
+
if ($2 instanceof Some) {
20668
20668
+
let loc = $2[0];
20669
20669
+
let location_json = object2(toList([
20670
20670
+
["name", string3(loc.name)],
20671
20671
+
["value", string3(loc.h3_index)]
20672
20672
+
]));
20673
20673
+
_block$2 = prepend(["home_town", location_json], json_fields$2);
20674
20674
+
} else {
20675
20675
+
_block$2 = json_fields$2;
20676
20676
+
}
20677
20677
+
let json_fields$3 = _block$2;
20678
20678
+
let _block$3;
20679
20679
+
let $3 = form_data.interests;
20680
20680
+
if ($3 === "") {
20681
20681
+
_block$3 = json_fields$3;
20682
20682
+
} else {
20683
20683
+
let interests_str = $3;
20684
20684
+
let _block$42;
20685
20685
+
let _pipe2 = split2(interests_str, ",");
20686
20686
+
let _pipe$12 = map(_pipe2, trim);
20687
20687
+
_block$42 = filter(_pipe$12, (s) => {
20688
20688
+
return s !== "";
20689
20689
+
});
20690
20690
+
let interests_list = _block$42;
20691
20691
+
_block$3 = prepend(["interests", array2(interests_list, string3)], json_fields$3);
20692
20692
+
}
20693
20693
+
let json_fields$4 = _block$3;
20694
20694
+
let _block$4;
20695
20695
+
let _pipe = object2(json_fields$4);
20696
20696
+
_block$4 = to_string2(_pipe);
20697
20697
+
let json_body = _block$4;
20698
20698
+
console_log("Sending profile update: " + json_body);
20699
20699
+
let _pipe$1 = postJson(url, json_body);
20700
20700
+
let _pipe$2 = map_promise(_pipe$1, (result) => {
20701
20701
+
if (result instanceof Ok) {
20702
20702
+
let $4 = result[0][0];
20703
20703
+
if ($4 === 200) {
20704
20704
+
console_log("Profile saved successfully");
20705
20705
+
return dispatch(new ProfileEditMsg(new SaveCompleted(new Ok(undefined))));
20706
20706
+
} else {
20707
20707
+
let status = $4;
20708
20708
+
let text4 = result[0][1];
20709
20709
+
console_log("Save failed with status " + to_string(status) + ": " + text4);
20710
20710
+
return dispatch(new ProfileEditMsg(new SaveCompleted(new Error2("Failed to save profile (status " + to_string(status) + ")"))));
20711
20711
+
}
20712
20712
+
} else {
20713
20713
+
let err = result[0];
20714
20714
+
console_log("Save request failed: " + err);
20715
20715
+
return dispatch(new ProfileEditMsg(new SaveCompleted(new Error2(err))));
20716
20716
+
}
20717
20717
+
});
20718
20718
+
then_await(_pipe$2, (_) => {
20719
20719
+
return resolve(undefined);
20720
20720
+
});
20721
20721
+
return;
20722
20722
+
});
20723
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
20604
-
_block = new FormData2(value3, _record.description, _record.location_input, _record.interests, _record.avatar_preview_url);
20817
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
20614
-
_block = new FormData2(_record.display_name, value3, _record.location_input, _record.interests, _record.avatar_preview_url);
20827
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
20624
-
_block = new FormData2(_record.display_name, _record.description, _record.location_input, value3, _record.avatar_preview_url);
20837
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
20641
-
_block = new FormData2(_record.display_name, _record.description, location_model, _record.interests, _record.avatar_preview_url);
20854
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
20653
-
console_log("Profile form submitted");
20654
20654
-
return [model, none()];
20866
20866
+
let _block;
20867
20867
+
let _record = model.edit_form_data;
20868
20868
+
_block = new FormData2(_record.display_name, _record.description, _record.location_input, _record.interests, _record.avatar_preview_url, new None, new None, true);
20869
20869
+
let form_data = _block;
20870
20870
+
let model$1 = new Model2(model.route, model.profile_state, form_data);
20871
20871
+
let $ = model$1.route;
20872
20872
+
if ($ instanceof ProfileEdit) {
20873
20873
+
let handle2 = $.handle;
20874
20874
+
return [model$1, save_profile_effect(handle2, model$1.edit_form_data)];
20875
20875
+
} else {
20876
20876
+
return [model$1, none()];
20877
20877
+
}
20878
20878
+
} else if (edit_msg instanceof SaveCompleted) {
20879
20879
+
let result = edit_msg[0];
20880
20880
+
let _block;
20881
20881
+
if (result instanceof Ok) {
20882
20882
+
let _record = model.edit_form_data;
20883
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
20884
+
} else {
20885
20885
+
let err = result[0];
20886
20886
+
let _record = model.edit_form_data;
20887
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
20888
+
}
20889
20889
+
let form_data = _block;
20890
20890
+
return [
20891
20891
+
new Model2(model.route, model.profile_state, form_data),
20892
20892
+
none()
20893
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
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
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
12
-
Config(
13
13
-
api_url: String,
14
14
-
slice_uri: String,
15
15
-
access_token: String,
16
16
-
)
12
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
78
-
_ -> Error("API returned status " <> string.inspect(resp.status) <> " with body: " <> resp.body)
74
74
+
_ ->
75
75
+
Error(
76
76
+
"API returned status "
77
77
+
<> string.inspect(resp.status)
78
78
+
<> " with body: "
79
79
+
<> resp.body,
80
80
+
)
79
81
}
80
82
}
81
83
82
84
/// Parse the GraphQL response and extract profile data
83
83
-
fn parse_profile_response(response_body: String) -> Result(Option(Profile), String) {
85
85
+
fn parse_profile_response(
86
86
+
response_body: String,
87
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
149
-
decode.at(
150
150
-
["node", "avatar", "url"],
151
151
-
decode.optional(decode.string),
152
152
-
),
153
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
212
+
213
213
+
pub type ProfileUpdate {
214
214
+
ProfileUpdate(
215
215
+
display_name: Option(String),
216
216
+
description: Option(String),
217
217
+
home_town: Option(json.Json),
218
218
+
interests: Option(List(String)),
219
219
+
)
220
220
+
}
221
221
+
222
222
+
/// Update profile via GraphQL mutation
223
223
+
pub fn update_profile(
224
224
+
config: Config,
225
225
+
_handle: String,
226
226
+
update: ProfileUpdate,
227
227
+
) -> Result(Nil, String) {
228
228
+
let mutation =
229
229
+
"
230
230
+
mutation UpdateProfile($rkey: String!, $input: OrgAtmosphereconfProfileInput!) {
231
231
+
updateOrgAtmosphereconfProfile(rkey: $rkey, input: $input) {
232
232
+
id
233
233
+
}
234
234
+
}
235
235
+
"
236
236
+
237
237
+
// Build input object
238
238
+
let input_fields = []
239
239
+
240
240
+
let input_fields = case update.display_name {
241
241
+
Some(val) -> [#("displayName", json.string(val)), ..input_fields]
242
242
+
None -> input_fields
243
243
+
}
244
244
+
245
245
+
let input_fields = case update.description {
246
246
+
Some(val) -> [#("description", json.string(val)), ..input_fields]
247
247
+
None -> input_fields
248
248
+
}
249
249
+
250
250
+
let input_fields = case update.home_town {
251
251
+
Some(val) -> [#("homeTown", val), ..input_fields]
252
252
+
None -> input_fields
253
253
+
}
254
254
+
255
255
+
let input_fields = case update.interests {
256
256
+
Some(val) -> [#("interests", json.array(val, json.string)), ..input_fields]
257
257
+
None -> input_fields
258
258
+
}
259
259
+
260
260
+
let variables =
261
261
+
json.object([
262
262
+
#("rkey", json.string("self")),
263
263
+
#("input", json.object(input_fields)),
264
264
+
])
265
265
+
266
266
+
let body_json =
267
267
+
json.object([
268
268
+
#("query", json.string(mutation)),
269
269
+
#("variables", variables),
270
270
+
])
271
271
+
272
272
+
// Build the HTTP request
273
273
+
use req <- result.try(
274
274
+
request.to(config.api_url)
275
275
+
|> result.map_error(fn(_) { "Failed to create request" }),
276
276
+
)
277
277
+
278
278
+
let req =
279
279
+
request.set_method(req, http.Post)
280
280
+
|> request.set_header("content-type", "application/json")
281
281
+
|> request.set_header("X-Slice-Uri", config.slice_uri)
282
282
+
|> request.set_header("Authorization", "Bearer " <> config.access_token)
283
283
+
|> request.set_body(json.to_string(body_json))
284
284
+
285
285
+
// Send the request
286
286
+
use resp <- result.try(
287
287
+
httpc.send(req)
288
288
+
|> result.map_error(fn(_) { "HTTP request failed" }),
289
289
+
)
290
290
+
291
291
+
// Check status code
292
292
+
case resp.status {
293
293
+
200 -> Ok(Nil)
294
294
+
_ ->
295
295
+
Error(
296
296
+
"API returned status "
297
297
+
<> string.inspect(resp.status)
298
298
+
<> " with body: "
299
299
+
<> resp.body,
300
300
+
)
301
301
+
}
302
302
+
}
+109
-2
server/src/server.gleam
···
1
1
import api/graphql
2
2
+
import gleam/dynamic/decode
2
3
import gleam/erlang/process
3
3
-
import gleam/http.{Get}
4
4
+
import gleam/http.{Get, Post}
4
5
import gleam/json
5
5
-
import gleam/option.{type Option}
6
6
+
import gleam/option.{type Option, None, Some}
7
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
56
+
57
57
+
// API endpoint to update profile
58
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
156
+
157
157
+
fn update_profile_json(req: Request, handle: String) -> Response {
158
158
+
let config = get_graphql_config()
159
159
+
160
160
+
wisp.log_info("API: Updating profile for handle: " <> handle)
161
161
+
162
162
+
// Parse request body
163
163
+
use body <- wisp.require_string_body(req)
164
164
+
165
165
+
// Decode JSON
166
166
+
let update_result = {
167
167
+
use parsed <- result.try(
168
168
+
json.parse(body, decode.dynamic)
169
169
+
|> result.map_error(fn(_) { "Invalid JSON" }),
170
170
+
)
171
171
+
172
172
+
// Extract fields from JSON
173
173
+
let display_name = case
174
174
+
decode.run(parsed, decode.at(["display_name"], decode.optional(decode.string)))
175
175
+
{
176
176
+
Ok(val) -> val
177
177
+
Error(_) -> None
178
178
+
}
179
179
+
180
180
+
let description = case
181
181
+
decode.run(parsed, decode.at(["description"], decode.optional(decode.string)))
182
182
+
{
183
183
+
Ok(val) -> val
184
184
+
Error(_) -> None
185
185
+
}
186
186
+
187
187
+
// Decode home_town as an object with name and value fields
188
188
+
let home_town = case
189
189
+
decode.run(
190
190
+
parsed,
191
191
+
decode.at(
192
192
+
["home_town"],
193
193
+
decode.optional({
194
194
+
use name <- decode.field("name", decode.string)
195
195
+
use value <- decode.field("value", decode.string)
196
196
+
decode.success(#(name, value))
197
197
+
}),
198
198
+
),
199
199
+
)
200
200
+
{
201
201
+
Ok(Some(#(name, value))) -> {
202
202
+
// Re-encode as JSON object
203
203
+
let json_obj = json.object([#("name", json.string(name)), #("value", json.string(value))])
204
204
+
Some(json_obj)
205
205
+
}
206
206
+
_ -> None
207
207
+
}
208
208
+
209
209
+
let interests = case
210
210
+
decode.run(
211
211
+
parsed,
212
212
+
decode.at(["interests"], decode.optional(decode.list(decode.string))),
213
213
+
)
214
214
+
{
215
215
+
Ok(val) -> val
216
216
+
Error(_) -> None
217
217
+
}
218
218
+
219
219
+
Ok(graphql.ProfileUpdate(
220
220
+
display_name: display_name,
221
221
+
description: description,
222
222
+
home_town: home_town,
223
223
+
interests: interests,
224
224
+
))
225
225
+
}
226
226
+
227
227
+
case update_result {
228
228
+
Ok(update) -> {
229
229
+
case graphql.update_profile(config, handle, update) {
230
230
+
Ok(_) -> {
231
231
+
wisp.log_info("API: Profile updated successfully for: " <> handle)
232
232
+
wisp.json_response(
233
233
+
json.to_string(json.object([
234
234
+
#("success", json.bool(True)),
235
235
+
#("message", json.string("Profile updated successfully")),
236
236
+
])),
237
237
+
200,
238
238
+
)
239
239
+
}
240
240
+
Error(err) -> {
241
241
+
wisp.log_error("API: Failed to update profile: " <> err)
242
242
+
wisp.json_response(
243
243
+
json.to_string(json.object([#("error", json.string(err))])),
244
244
+
500,
245
245
+
)
246
246
+
}
247
247
+
}
248
248
+
}
249
249
+
Error(err) -> {
250
250
+
wisp.log_error("API: Failed to parse update request: " <> err)
251
251
+
wisp.json_response(
252
252
+
json.to_string(json.object([#("error", json.string(err))])),
253
253
+
400,
254
254
+
)
255
255
+
}
256
256
+
}
257
257
+
}