forked from
smokesignal.events/smokesignal
i18n+filtering fork - fluent-templates v2
1use std::collections::BTreeMap;
2use std::collections::HashMap;
3
4use anyhow::Result;
5use axum::extract::Query;
6use axum::extract::State;
7use axum::response::IntoResponse;
8use axum_extra::extract::Cached;
9use axum_extra::extract::Form;
10use axum_htmx::HxBoosted;
11use axum_htmx::HxRequest;
12use chrono::Utc;
13use http::Method;
14use http::StatusCode;
15use minijinja::context as template_context;
16use serde::Deserialize;
17
18use crate::atproto::auth::SimpleOAuthSessionProvider;
19use crate::atproto::client::CreateRecordRequest;
20use crate::atproto::client::OAuthPdsClient;
21use crate::atproto::lexicon::community::lexicon::calendar::event::Event;
22use crate::atproto::lexicon::community::lexicon::calendar::event::EventLink;
23use crate::atproto::lexicon::community::lexicon::calendar::event::EventLocation;
24use crate::atproto::lexicon::community::lexicon::calendar::event::Mode;
25use crate::atproto::lexicon::community::lexicon::calendar::event::Status;
26use crate::atproto::lexicon::community::lexicon::calendar::event::NSID;
27use crate::atproto::lexicon::community::lexicon::location::Address;
28use crate::create_renderer;
29use crate::http::context::WebContext;
30use crate::http::errors::CommonError;
31use crate::http::errors::CreateEventError;
32use crate::http::errors::WebError;
33use crate::http::event_form::BuildEventContentState;
34use crate::http::event_form::BuildEventForm;
35use crate::http::event_form::BuildLinkForm;
36use crate::http::event_form::BuildStartsForm;
37use crate::http::middleware_auth::Auth;
38use crate::http::middleware_i18n::Language;
39use crate::http::timezones::supported_timezones;
40use crate::http::utils::url_from_aturi;
41use crate::storage::event::event_insert;
42
43use super::cache_countries::cached_countries;
44use super::event_form::BuildLocationForm;
45
46pub async fn handle_create_event(
47 method: Method,
48 State(web_context): State<WebContext>,
49 Language(language): Language,
50 Cached(auth): Cached<Auth>,
51 HxRequest(hx_request): HxRequest,
52 HxBoosted(hx_boosted): HxBoosted,
53 Form(mut build_event_form): Form<BuildEventForm>,
54) -> Result<impl IntoResponse, WebError> {
55 let current_handle = auth.require(&web_context.config.destination_key, "/event")?;
56
57 // Create the template renderer with enhanced context
58 let renderer = create_renderer!(web_context.clone(), Language(language.clone()), hx_boosted, hx_request);
59
60 let canonical_url = format!("https://{}/event", web_context.config.external_base);
61 let is_development = cfg!(debug_assertions);
62
63 let default_context = template_context! {
64 is_development,
65 create_event => true,
66 submit_url => format!("/event"),
67 };
68
69 let (default_tz, timezones) = supported_timezones(auth.0.as_ref());
70
71 if build_event_form.build_state.is_none() {
72 build_event_form.build_state = Some(BuildEventContentState::default());
73 }
74
75 let mut starts_form = BuildStartsForm::from(build_event_form.clone());
76 if starts_form.build_state.is_none() {
77 starts_form.build_state = Some(BuildEventContentState::default());
78 }
79
80 if starts_form.tz.is_none() {
81 starts_form.tz = Some(default_tz.to_string());
82 }
83
84 let mut location_form = BuildLocationForm::from(build_event_form.clone());
85 if location_form.build_state.is_none() {
86 location_form.build_state = Some(BuildEventContentState::default());
87 }
88
89 let mut link_form = BuildLinkForm::from(build_event_form.clone());
90 if link_form.build_state.is_none() {
91 link_form.build_state = Some(BuildEventContentState::default());
92 }
93
94 if method == Method::GET {
95 #[cfg(debug_assertions)]
96 {
97 build_event_form.name = Some("My awesome event".to_string());
98 build_event_form.description = Some("A really great event.".to_string());
99 }
100
101 // Set default values for required fields
102 if build_event_form.status.is_none() {
103 build_event_form.status = Some("scheduled".to_string());
104 }
105
106 if build_event_form.mode.is_none() {
107 build_event_form.mode = Some("inperson".to_string());
108 }
109
110 build_event_form.build_state = Some(BuildEventContentState::Selecting);
111 starts_form.build_state = Some(BuildEventContentState::Selected);
112
113 // Set default start time to 6:00 PM, 6 hours from now
114 let now = Utc::now(); // + chrono::Duration::hours(6);
115
116 // Parse default timezone string to a Tz object
117 let parsed_tz = default_tz
118 .parse::<chrono_tz::Tz>()
119 .unwrap_or(chrono_tz::UTC);
120
121 // Get the date in the target timezone
122 let local_date = now.with_timezone(&parsed_tz).date_naive();
123
124 // Create a datetime at 6:00 PM on that date
125 if let Some(naive_dt) = local_date.and_hms_opt(18, 0, 0) {
126 // Convert to timezone-aware datetime
127 let local_dt = naive_dt.and_local_timezone(parsed_tz).single().unwrap();
128 let utc_dt = local_dt.with_timezone(&Utc);
129
130 // Format the date and time as expected by the form
131 starts_form.starts_date = Some(local_dt.format("%Y-%m-%d").to_string());
132 starts_form.starts_time = Some(local_dt.format("%H:%M").to_string());
133 starts_form.starts_at = Some(utc_dt.to_string());
134 starts_form.starts_display = Some(local_dt.format("%A, %B %-d, %Y %r %Z").to_string());
135
136 build_event_form.starts_at = starts_form.starts_at.clone();
137 }
138
139 return Ok(renderer.render_template(
140 "create_event",
141 template_context! {
142 is_development,
143 create_event => true,
144 submit_url => format!("/event"),
145 build_event_form,
146 starts_form,
147 location_form,
148 link_form,
149 timezones,
150 },
151 Some(¤t_handle),
152 &canonical_url,
153 ));
154 }
155
156 match build_event_form.build_state {
157 Some(BuildEventContentState::Reset) => {
158 build_event_form.build_state = Some(BuildEventContentState::Selecting);
159 build_event_form.name = None;
160 build_event_form.name_error = None;
161 build_event_form.description = None;
162 build_event_form.description_error = None;
163 build_event_form.status = Some("planned".to_string());
164 build_event_form.status_error = None;
165 build_event_form.starts_at = None;
166 build_event_form.starts_at_error = None;
167 build_event_form.ends_at = None;
168 build_event_form.ends_at_error = None;
169 build_event_form.mode = Some("inperson".to_string());
170 build_event_form.mode_error = None;
171 }
172 Some(BuildEventContentState::Selected) => {
173 let found_errors =
174 build_event_form.validate(&web_context.i18n_context.locales, &language);
175 if found_errors {
176 build_event_form.build_state = Some(BuildEventContentState::Selecting);
177 } else {
178 build_event_form.build_state = Some(BuildEventContentState::Selected);
179 }
180
181 if !found_errors {
182 // 1. Compose an event record
183
184 let now = Utc::now();
185
186 let starts_at = build_event_form
187 .starts_at
188 .as_ref()
189 .and_then(|v| v.parse::<chrono::DateTime<Utc>>().ok());
190 let ends_at = build_event_form
191 .ends_at
192 .as_ref()
193 .and_then(|v| v.parse::<chrono::DateTime<Utc>>().ok());
194
195 let mode = build_event_form
196 .mode
197 .as_ref()
198 .and_then(|v| match v.as_str() {
199 "inperson" => Some(Mode::InPerson),
200 "virtual" => Some(Mode::Virtual),
201 "hybrid" => Some(Mode::Hybrid),
202 _ => None,
203 });
204
205 let status = build_event_form
206 .status
207 .as_ref()
208 .and_then(|v| match v.as_str() {
209 "planned" => Some(Status::Planned),
210 "scheduled" => Some(Status::Scheduled),
211 "cancelled" => Some(Status::Cancelled),
212 "postponed" => Some(Status::Postponed),
213 "rescheduled" => Some(Status::Rescheduled),
214 _ => None,
215 });
216
217 // Ensure we have auth data for the API call
218 let auth_data = auth.1.ok_or(CommonError::NotAuthorized)?;
219 let client_auth: SimpleOAuthSessionProvider =
220 SimpleOAuthSessionProvider::try_from(auth_data)?;
221
222 let client = OAuthPdsClient {
223 http_client: &web_context.http_client,
224 pds: ¤t_handle.pds,
225 };
226
227 let locations = match &build_event_form.location_country {
228 Some(country) => vec![EventLocation::Address(Address::Current {
229 country: country.clone(),
230 postal_code: build_event_form.location_postal_code.clone(),
231 region: build_event_form.location_region.clone(),
232 locality: build_event_form.location_locality.clone(),
233 street: build_event_form.location_street.clone(),
234 name: build_event_form.location_name.clone(),
235 })],
236 None => vec![],
237 };
238
239 // Process link if provided
240 let links = match &build_event_form.link_value {
241 Some(uri) => vec![EventLink::Current {
242 uri: uri.clone(),
243 name: build_event_form.link_name.clone(),
244 }],
245 None => vec![],
246 };
247
248 let the_record = Event::Current {
249 name: build_event_form
250 .name
251 .clone()
252 .ok_or(CreateEventError::NameNotSet)?,
253 description: build_event_form
254 .description
255 .clone()
256 .ok_or(CreateEventError::DescriptionNotSet)?,
257 created_at: now,
258 starts_at,
259 ends_at,
260 mode,
261 status,
262 locations,
263 uris: links,
264 extra: HashMap::default(),
265 };
266
267 let event_record = CreateRecordRequest {
268 repo: current_handle.did.clone(),
269 collection: NSID.to_string(),
270 validate: false,
271 record_key: None,
272 record: the_record.clone(),
273 swap_commit: None,
274 };
275
276 let create_record_result = client.create_record(&client_auth, event_record).await;
277
278 if let Err(err) = create_record_result {
279 return Ok(renderer.render_error(err, default_context));
280 }
281
282 // create_record_result is guaranteed to be Ok since we checked for Err above
283 let create_record_result = create_record_result?;
284
285 let event_insert_result = event_insert(
286 &web_context.pool,
287 &create_record_result.uri,
288 &create_record_result.cid,
289 ¤t_handle.did,
290 NSID,
291 &the_record,
292 )
293 .await;
294
295 if let Err(err) = event_insert_result {
296 return Ok(renderer.render_error(err, default_context));
297 }
298
299 let event_url =
300 url_from_aturi(&web_context.config.external_base, &create_record_result.uri)?;
301
302 return Ok(renderer.render_template(
303 "create_event",
304 template_context! {
305 is_development,
306 create_event => true,
307 submit_url => format!("/event"),
308 build_event_form,
309 starts_form,
310 location_form,
311 link_form,
312 operation_completed => true,
313 event_url,
314 },
315 Some(¤t_handle),
316 &canonical_url,
317 ));
318 }
319 }
320 _ => {}
321 }
322
323 Ok(renderer.render_template(
324 "create_event",
325 template_context! {
326 is_development,
327 create_event => true,
328 submit_url => format!("/event"),
329 build_event_form,
330 starts_form,
331 timezones,
332 location_form,
333 link_form,
334 },
335 Some(¤t_handle),
336 &canonical_url,
337 ))
338}
339
340pub async fn handle_starts_at_builder(
341 method: Method,
342 State(web_context): State<WebContext>,
343 Language(language): Language,
344 Cached(auth): Cached<Auth>,
345 HxRequest(hx_request): HxRequest,
346 Form(mut starts_form): Form<BuildStartsForm>,
347) -> Result<impl IntoResponse, WebError> {
348 if !hx_request {
349 return Ok(StatusCode::BAD_REQUEST.into_response());
350 }
351
352 if auth.require_flat().is_err() {
353 return Ok(StatusCode::BAD_REQUEST.into_response());
354 }
355
356 let (default_tz, timezones) = supported_timezones(auth.0.as_ref());
357
358 let is_development = cfg!(debug_assertions);
359
360 // Create the template renderer for HTMX content replacement
361 let renderer = create_renderer!(web_context.clone(), Language(language.clone()), false, false);
362
363 let canonical_url = format!("https://{}/event", web_context.config.external_base);
364
365 if starts_form.build_state.is_none() {
366 starts_form.build_state = Some(BuildEventContentState::default());
367 }
368 if starts_form.tz.is_none() {
369 starts_form.tz = Some(default_tz.to_string());
370 }
371
372 if method == Method::GET {
373 return Ok(renderer.render_template(
374 "create_event.starts_form",
375 template_context! {
376 starts_form,
377 is_development,
378 timezones,
379 },
380 None,
381 &canonical_url,
382 ));
383 }
384
385 if starts_form
386 .build_state
387 .as_ref()
388 .is_some_and(|value| value == &BuildEventContentState::Reset)
389 {
390 starts_form.tz = Some(default_tz.to_string());
391 starts_form.tz_error = None;
392 starts_form.starts_at = None;
393 starts_form.starts_time = None;
394 starts_form.starts_date = None;
395 starts_form.ends_at = None;
396 starts_form.ends_time = None;
397 starts_form.ends_date = None;
398 starts_form.include_ends = None;
399 }
400
401 if starts_form
402 .build_state
403 .as_ref()
404 .is_some_and(|value| value == &BuildEventContentState::Selected)
405 {
406 let found_errors = starts_form.validate(&web_context.i18n_context.locales, &language);
407 if found_errors {
408 starts_form.build_state = Some(BuildEventContentState::Selecting);
409 } else {
410 starts_form.build_state = Some(BuildEventContentState::Selected);
411
412 if starts_form.ends_display.is_none() {
413 starts_form.ends_display = Some("--".to_string());
414 }
415 }
416 }
417
418 Ok(renderer.render_template(
419 "create_event.starts_form",
420 template_context! {
421 starts_form,
422 is_development,
423 timezones,
424 },
425 None,
426 &canonical_url,
427 ))
428}
429
430pub async fn handle_location_at_builder(
431 method: Method,
432 State(web_context): State<WebContext>,
433 Language(language): Language,
434 Cached(auth): Cached<Auth>,
435 HxRequest(hx_request): HxRequest,
436 Form(mut location_form): Form<BuildLocationForm>,
437) -> Result<impl IntoResponse, WebError> {
438 if !hx_request {
439 return Ok(StatusCode::BAD_REQUEST.into_response());
440 }
441
442 if auth.require_flat().is_err() {
443 return Ok(StatusCode::BAD_REQUEST.into_response());
444 }
445
446 let is_development = cfg!(debug_assertions);
447
448 // Create the template renderer for HTMX content replacement
449 let renderer = create_renderer!(web_context.clone(), Language(language.clone()), false, false);
450
451 let canonical_url = format!("https://{}/event", web_context.config.external_base);
452
453 if location_form.build_state.is_none() {
454 location_form.build_state = Some(BuildEventContentState::default());
455 }
456
457 if method == Method::GET {
458 return Ok(renderer.render_template(
459 "create_event.location_form",
460 template_context! {
461 location_form,
462 is_development
463 },
464 None,
465 &canonical_url,
466 ));
467 }
468
469 if location_form
470 .build_state
471 .as_ref()
472 .is_some_and(|value| value == &BuildEventContentState::Reset)
473 {
474 location_form.location_country = None;
475 location_form.location_country_error = None;
476 location_form.location_name = None;
477 location_form.location_name_error = None;
478 }
479
480 if location_form
481 .build_state
482 .as_ref()
483 .is_some_and(|value| value == &BuildEventContentState::Selected)
484 {
485 let found_errors = location_form.validate(&web_context.i18n_context.locales, &language);
486 if found_errors {
487 location_form.build_state = Some(BuildEventContentState::Selecting);
488 } else {
489 location_form.build_state = Some(BuildEventContentState::Selected);
490 }
491 }
492
493 Ok(renderer.render_template(
494 "create_event.location_form",
495 template_context! {
496 location_form,
497 is_development,
498 },
499 None,
500 &canonical_url,
501 ))
502}
503
504pub async fn handle_link_at_builder(
505 method: Method,
506 State(web_context): State<WebContext>,
507 Language(language): Language,
508 Cached(auth): Cached<Auth>,
509 HxRequest(hx_request): HxRequest,
510 Form(mut link_form): Form<BuildLinkForm>,
511) -> Result<impl IntoResponse, WebError> {
512 if !hx_request {
513 return Ok(StatusCode::BAD_REQUEST.into_response());
514 }
515
516 if auth.require_flat().is_err() {
517 return Ok(StatusCode::BAD_REQUEST.into_response());
518 }
519
520 let is_development = cfg!(debug_assertions);
521
522 // Create the template renderer for HTMX content replacement
523 let renderer = create_renderer!(web_context.clone(), Language(language.clone()), false, false);
524
525 let canonical_url = format!("https://{}/event", web_context.config.external_base);
526
527 if link_form.build_state.is_none() {
528 link_form.build_state = Some(BuildEventContentState::default());
529 }
530
531 if method == Method::GET {
532 return Ok(renderer.render_template(
533 "create_event.link_form",
534 template_context! {
535 link_form,
536 is_development
537 },
538 None,
539 &canonical_url,
540 ));
541 }
542
543 if link_form
544 .build_state
545 .as_ref()
546 .is_some_and(|value| value == &BuildEventContentState::Reset)
547 {
548 link_form.link_name = None;
549 link_form.link_name_error = None;
550 link_form.link_value = None;
551 link_form.link_value_error = None;
552 }
553
554 if link_form
555 .build_state
556 .as_ref()
557 .is_some_and(|value| value == &BuildEventContentState::Selected)
558 {
559 let found_errors = link_form.validate(&web_context.i18n_context.locales, &language);
560 if found_errors {
561 link_form.build_state = Some(BuildEventContentState::Selecting);
562 } else {
563 link_form.build_state = Some(BuildEventContentState::Selected);
564 }
565 }
566
567 Ok(renderer.render_template(
568 "create_event.link_form",
569 template_context! {
570 link_form,
571 is_development,
572 },
573 None,
574 &canonical_url,
575 ))
576}
577
578#[derive(Deserialize, Debug, Clone)]
579pub struct LocationDataListHint {
580 pub location_country: Option<String>,
581}
582
583pub async fn handle_location_datalist(
584 State(web_context): State<WebContext>,
585 Language(language): Language,
586 HxRequest(hx_request): HxRequest,
587 Query(location_country_hint): Query<LocationDataListHint>,
588) -> Result<impl IntoResponse, WebError> {
589 if !hx_request {
590 return Ok(StatusCode::BAD_REQUEST.into_response());
591 }
592
593 // Create the template renderer for HTMX content replacement
594 let renderer = create_renderer!(web_context.clone(), Language(language), false, false);
595
596 let canonical_url = format!("https://{}/event", web_context.config.external_base);
597
598 let all_countries = cached_countries()?;
599
600 let locations = if let Some(value) = location_country_hint.location_country {
601 prefixed((**all_countries).clone(), &value)
602 .iter()
603 .take(30)
604 .map(|(k, v)| (v.clone(), k.clone()))
605 .collect::<Vec<(String, String)>>()
606 } else {
607 all_countries
608 .iter()
609 .take(30)
610 .map(|(k, v)| (v.clone(), k.clone()))
611 .collect::<Vec<(String, String)>>()
612 };
613
614 Ok(renderer.render_template(
615 "create_event.countries_datalist",
616 template_context! {
617 locations,
618 },
619 None,
620 &canonical_url,
621 ))
622}
623
624// Nick: The next two methods were adapted from https://www.thecodedmessage.com/posts/prefix-ranges/ which has no license. Thank you.
625
626fn upper_bound_from_prefix(prefix: &str) -> Option<String> {
627 for i in (0..prefix.len()).rev() {
628 if let Some(last_char_str) = prefix.get(i..) {
629 let rest_of_prefix = {
630 debug_assert!(prefix.is_char_boundary(i));
631 &prefix[0..i]
632 };
633
634 let last_char = last_char_str
635 .chars()
636 .next()
637 .expect("last_char_str will contain at least one char");
638 let Some(last_char_incr) = (last_char..=char::MAX).nth(1) else {
639 // Last character is highest possible code point.
640 // Go to second-to-last character instead.
641 continue;
642 };
643
644 let new_string = format!("{rest_of_prefix}{last_char_incr}");
645
646 return Some(new_string);
647 }
648 }
649
650 None
651}
652
653pub fn prefixed(mut set: BTreeMap<String, String>, prefix: &str) -> BTreeMap<String, String> {
654 let mut set = set.split_off(prefix);
655
656 if let Some(not_in_prefix) = upper_bound_from_prefix(prefix) {
657 set.split_off(¬_in_prefix);
658 }
659
660 set
661}