diff --git a/PHASE_4_COMPLETE.md b/PHASE_4_COMPLETE.md deleted file mode 100644 index e69de29..0000000 diff --git a/docs/middleware_usage_example.rs b/docs/middleware_usage_example.rs new file mode 100644 index 0000000..b813de1 --- /dev/null +++ b/docs/middleware_usage_example.rs @@ -0,0 +1,134 @@ +use axum::{ + extract::{Extension, Request}, + http::StatusCode, + middleware, + response::Json, + routing::get, + Router, +}; +use serde_json::json; +use std::net::SocketAddr; + +// Import du middleware (assumant qu'il soit dans un module séparé) +// mod timezone_middleware; +// use timezone_middleware::*; + +// Handler simple qui utilise le timezone détecté +async fn hello_with_timezone(Extension(timezone): Extension) -> Json { + Json(json!({ + "message": "Bonjour!", + "your_timezone": timezone.timezone, + "detected_by": timezone.source, + "coordinates": timezone.coordinates + })) +} + +// Handler avec extraction manuelle +async fn timezone_info(request: Request) -> Json { + if let Some(timezone) = request.extensions().get::() { + Json(json!({ + "timezone": timezone.timezone, + "source": timezone.source, + "coordinates": timezone.coordinates, + "status": "detected" + })) + } else { + Json(json!({ + "timezone": "UTC", + "source": "fallback", + "coordinates": null, + "status": "fallback" + })) + } +} + +// Handler qui utilise le timezone pour formatter une date +async fn current_time(Extension(timezone): Extension) -> Json { + use chrono::{DateTime, Utc}; + + let now = Utc::now(); + + Json(json!({ + "utc_time": now.to_rfc3339(), + "timezone": timezone.timezone, + "local_time_info": format!("Time in {}", timezone.timezone), + "source": timezone.source + })) +} + +// Middleware personnalisé qui peut utiliser le timezone +async fn log_timezone_middleware( + request: Request, + next: middleware::Next, +) -> Result { + // Le timezone a déjà été détecté par le middleware précédent + if let Some(timezone) = request.extensions().get::() { + println!("Request from timezone: {} (via {})", timezone.timezone, timezone.source); + } + + Ok(next.run(request).await) +} + +#[tokio::main] +async fn main() { + // Configuration de l'application avec le middleware + let app = Router::new() + .route("/", get(hello_with_timezone)) + .route("/timezone", get(timezone_info)) + .route("/time", get(current_time)) + // Appliquer le middleware de timezone en premier + .layer(middleware::from_fn(timezone_middleware)) + // Puis le middleware de logging + .layer(middleware::from_fn(log_timezone_middleware)) + .into_make_service_with_connect_info::(); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:3000") + .await + .unwrap(); + + println!("🚀 Serveur démarré sur http://127.0.0.1:3000"); + println!("📍 Endpoints disponibles:"); + println!(" GET / - Message avec timezone"); + println!(" GET /timezone - Info détaillée du timezone"); + println!(" GET /time - Heure actuelle avec timezone"); + + axum::serve(listener, app).await.unwrap(); +} + +// Version alternative avec configuration personnalisée +pub fn create_app_with_custom_timezone_config() -> Router { + let timezone_config = TimezoneConfig { + cache_duration: Duration::from_secs(1800), // 30 minutes + request_timeout: Duration::from_secs(5), // 5 secondes + fallback_timezone: "Europe/Paris".to_string(), + }; + + Router::new() + .route("/", get(hello_with_timezone)) + .route("/timezone", get(timezone_info)) + .layer(middleware::from_fn_with_state( + timezone_config, + timezone_middleware_with_config + )) +} + +// Middleware avec configuration personnalisée +async fn timezone_middleware_with_config( + State(config): axum::extract::State, + ConnectInfo(addr): axum::extract::ConnectInfo, + headers: axum::http::HeaderMap, + mut request: Request, + next: middleware::Next, +) -> Result { + let cache: TimezoneCache = Arc::new(Mutex::new(HashMap::new())); + + let detected_timezone = detect_timezone_with_cache( + addr.ip(), + &headers, + &config, + &cache, + ).await; + + request.extensions_mut().insert(detected_timezone); + Ok(next.run(request).await) +} diff --git a/src/filtering/facets.rs b/src/filtering/facets.rs index 4865837..83dc551 100644 --- a/src/filtering/facets.rs +++ b/src/filtering/facets.rs @@ -608,6 +608,7 @@ impl FacetCalculator { } /// Get display name for mode facet value (locale-aware) + #[allow(dead_code)] fn mode_display_name(mode: &str, locale: &LanguageIdentifier) -> String { let lang_str = locale.language.as_str(); match mode { @@ -631,6 +632,7 @@ impl FacetCalculator { } /// Get display name for status facet value (locale-aware) + #[allow(dead_code)] fn status_display_name(status: &str, locale: &LanguageIdentifier) -> String { let lang_str = locale.language.as_str(); match status { @@ -664,6 +666,7 @@ impl FacetCalculator { } /// Get display name for date range facet (locale-aware) + #[allow(dead_code)] fn date_range_display_name(range_key: &str, locale: &LanguageIdentifier) -> String { let lang_str = locale.language.as_str(); match range_key { diff --git a/src/filtering/hydration.rs b/src/filtering/hydration.rs index 7d75882..1981ac2 100644 --- a/src/filtering/hydration.rs +++ b/src/filtering/hydration.rs @@ -190,28 +190,50 @@ impl EventHydrator { /// Create an EventView from an Event for RSVP hydration async fn create_event_view(&self, event: &Event) -> Result { - // This is a simplified conversion - in a real implementation, - // you'd want to properly parse the event record and create the EventView + // Extract event details from the record JSON + let event_details = crate::storage::event::extract_event_details(event); + + // Parse the AT-URI to extract components + let parsed_uri = crate::atproto::uri::parse_aturi(&event.aturi) + .map_err(|_| FilterError::Hydration( + format!("Failed to parse AT-URI: {}", event.aturi) + ))?; + + // Get organizer display name (fallback to DID prefix if not available) + let organizer_display_name = format!("did:{}...", &event.did[4..12]); + let event_view = EventView { - site_url: "https://smokesignal.events".to_string(), // Default site URL + site_url: "https://smokesignal.events".to_string(), aturi: event.aturi.clone(), cid: event.cid.clone(), - repository: event.did.clone(), // Use DID as repository - collection: event.lexicon.clone(), + repository: parsed_uri.0, + collection: parsed_uri.1, organizer_did: event.did.clone(), - organizer_display_name: "Unknown".to_string(), // Would be populated by handle lookup - starts_at_machine: None, // Would be populated by record parsing - starts_at_human: None, - ends_at_machine: None, - ends_at_human: None, - name: event.name.clone(), - description: None, // Would be populated by record parsing - description_short: None, + organizer_display_name, + starts_at_machine: event_details.starts_at + .map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string()), + starts_at_human: event_details.starts_at + .map(|dt| dt.format("%B %d, %Y at %l:%M %p").to_string()), + ends_at_machine: event_details.ends_at + .map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string()), + ends_at_human: event_details.ends_at + .map(|dt| dt.format("%B %d, %Y at %l:%M %p").to_string()), + name: event_details.name.to_string(), + description: if event_details.description.is_empty() { + None + } else { + Some(crate::http::utils::truncate_text(&event_details.description, 500, None)) + }, + description_short: if event_details.description.is_empty() { + None + } else { + Some(crate::http::utils::truncate_text(&event_details.description, 200, None)) + }, count_going: 0, count_interested: 0, count_notgoing: 0, - mode: None, // Would be populated by record parsing - status: None, + mode: event_details.mode.map(|m| m.to_string()), + status: event_details.status.map(|s| s.to_string()), address_display: None, links: Vec::new(), }; diff --git a/src/filtering/service.rs b/src/filtering/service.rs index 18c0321..11422dd 100644 --- a/src/filtering/service.rs +++ b/src/filtering/service.rs @@ -156,6 +156,7 @@ impl FilteringService { } /// Filter events without caching + #[allow(dead_code)] async fn filter_events_uncached( &self, criteria: &EventFilterCriteria, diff --git a/src/http/handle_filter_events.rs b/src/http/handle_filter_events.rs index fbc20bf..0aa180b 100644 --- a/src/http/handle_filter_events.rs +++ b/src/http/handle_filter_events.rs @@ -12,13 +12,55 @@ use axum_htmx::{HxBoosted, HxRequest}; use axum_template::RenderHtml; use minijinja::context as template_context; use serde::{Deserialize, Serialize}; +use serde_json::Value; use tracing::{instrument, warn}; -use crate::filtering::{EventFilterCriteria, FilteringService, FilterOptions}; +use crate::filtering::{EventFilterCriteria, EventSortField, FilteringService, FilterOptions, SortOrder, HydratedEvent}; use crate::http::context::UserRequestContext; use crate::http::errors::{CommonError, WebError}; use crate::http::middleware_filter::{FilterCriteriaExtension, FilterQueryParams}; -use crate::storage::StoragePool; +use crate::storage::{StoragePool, event::{get_user_rsvp, extract_event_details}}; + +/// Template-compatible event data with flattened structure +#[derive(Debug, Clone, Serialize)] +pub struct TemplateEvent { + // Core event identification + pub aturi: String, + pub cid: String, + pub site_url: String, + pub collection: String, // Added for legacy detection in templates + + // Event metadata + pub name: String, + pub description: Option, + pub description_short: Option, + + // Organizer information + pub organizer_did: String, + pub organizer_display_name: String, + + // Date/time information + pub starts_at_machine: Option, + pub starts_at_human: Option, + pub ends_at_machine: Option, + pub ends_at_human: Option, + + // Event properties + pub mode: Option, + pub status: Option, + pub address_display: Option, + + // RSVP counts + pub count_going: u32, + pub count_interested: u32, + pub count_not_going: u32, + + // User's relationship to the event (for RSVP status display) + pub role: Option, + + // Additional properties + pub links: Vec<(String, Option)>, +} /// Query parameters for filtering pages #[derive(Debug, Deserialize, Serialize)] @@ -60,11 +102,21 @@ pub async fn handle_filter_events( CommonError::FailedToParse })?; + // Transform hydrated events to template-compatible format + let user_did = ctx.current_handle.as_ref().map(|h| h.did.as_str()); + let mut template_events = Vec::new(); + + for hydrated in &results.hydrated_events { + let template_event = hydrated_event_to_template(hydrated, user_did, &ctx.web_context.pool).await; + template_events.push(template_event); + } + // Prepare template context let template_ctx = template_context! { - events => results.hydrated_events, + events => template_events, facets => results.facets, - criteria => criteria, + criteria => criteria.clone(), + filter_criteria => criteria_to_template_context(&criteria), total_count => results.total_count, page => results.page, page_size => results.page_size, @@ -74,16 +126,16 @@ pub async fn handle_filter_events( is_first_page => results.is_first_page(), is_last_page => results.is_last_page(), raw_params => FilterQueryParams::default(), - current_locale => locale_str, + current_locale => locale_str.to_lowercase(), }; // Choose template based on request type let template_name = if is_htmx && !boosted && !page_query.full.unwrap_or(false) { // HTMX partial response - just the results section - format!("filter_events_results.{}.incl.html", ctx.language.0) + format!("filter_events_results.{}.incl.html", ctx.language.to_string().to_lowercase()) } else { // Full page response - format!("filter_events.{}.html", ctx.language.0) + format!("filter_events.{}.html", ctx.language.to_string().to_lowercase()) }; // Render template using RenderHtml @@ -125,12 +177,13 @@ pub async fn handle_filter_facets( // Prepare template context let template_ctx = template_context! { facets => facets, - criteria => criteria, - current_locale => locale_str, + criteria => criteria.clone(), + filter_criteria => criteria_to_template_context(&criteria), + current_locale => locale_str.to_lowercase(), }; // Render facets template using RenderHtml - let template_name = format!("filter_events_facets.{}.incl.html", ctx.language.0); + let template_name = format!("filter_events_facets.{}.incl.html", ctx.language.to_string().to_lowercase()); Ok(RenderHtml(template_name, ctx.web_context.engine.clone(), template_ctx).into_response()) } @@ -162,11 +215,11 @@ pub async fn handle_filter_suggestions( let template_ctx = template_context! { events => results.hydrated_events, query => query.q, - current_locale => locale_str, + current_locale => locale_str.to_lowercase(), }; // Render suggestions template using RenderHtml - let template_name = format!("filter_events_suggestions.{}.incl.html", ctx.language.0); + let template_name = format!("filter_events_suggestions.{}.incl.html", ctx.language.to_string().to_lowercase()); Ok(RenderHtml(template_name, ctx.web_context.engine.clone(), template_ctx).into_response()) } @@ -180,6 +233,219 @@ pub struct SuggestionQuery { pub limit: Option, } +/// Convert EventFilterCriteria to template-friendly format +fn criteria_to_template_context(criteria: &EventFilterCriteria) -> Value { + use serde_json::json; + + // Format date fields as strings for HTML date inputs + let start_date = criteria.start_date + .map(|d| d.format("%Y-%m-%d").to_string()) + .unwrap_or_default(); + + let end_date = criteria.end_date + .map(|d| d.format("%Y-%m-%d").to_string()) + .unwrap_or_default(); + + // Extract location fields + let (location, radius_km) = if let Some(ref loc) = criteria.location { + (format!("{}, {}", loc.latitude, loc.longitude), loc.radius_km as i32) + } else { + (String::new(), 0) + }; + + // Convert sort enum to string values expected by template + let sort_by = match criteria.sort_by { + EventSortField::StartTime => { + match criteria.sort_order { + SortOrder::Ascending => "date_asc", + SortOrder::Descending => "date_desc", + } + }, + EventSortField::UpdatedAt => { + match criteria.sort_order { + SortOrder::Ascending => "updated_asc", + SortOrder::Descending => "updated_desc", + } + }, + EventSortField::Name => { + match criteria.sort_order { + SortOrder::Ascending => "relevance", + SortOrder::Descending => "relevance", + } + }, + EventSortField::PopularityRsvp => { + match criteria.sort_order { + SortOrder::Ascending => "popularity_asc", + SortOrder::Descending => "popularity_desc", + } + }, + }; + + // Convert modes to string values + let modes: Vec = criteria.modes.iter() + .map(|m| format!("{:?}", m)) + .collect(); + + // Convert statuses to string values + let statuses: Vec = criteria.statuses.iter() + .map(|s| format!("{:?}", s)) + .collect(); + + // Convert creator_did to creator_dids array (template expects array) + let creator_dids: Vec = criteria.creator_did + .as_ref() + .map(|did| vec![did.clone()]) + .unwrap_or_default(); + + json!({ + "text_search": criteria.search_term.as_deref().unwrap_or(""), + "start_date": start_date, + "end_date": end_date, + "location": location, + "radius_km": radius_km, + "sort_by": sort_by, + "modes": modes, + "statuses": statuses, + "date_ranges": Vec::::new(), // Template expects this but we don't use it yet + "creator_dids": creator_dids, + }) +} + +/// Convert HydratedEvent to template-compatible flattened structure +async fn hydrated_event_to_template( + hydrated: &HydratedEvent, + user_did: Option<&str>, + pool: &StoragePool, +) -> TemplateEvent { + let event = &hydrated.event; + + // Extract event details from the JSON record + let event_details = extract_event_details(event); + + // Parse the AT-URI to extract collection + let parsed_uri = crate::atproto::uri::parse_aturi(&event.aturi) + .unwrap_or(("".to_string(), event.lexicon.clone(), "".to_string())); + let collection = parsed_uri.1; + + // Extract basic event properties + let mut template_event = TemplateEvent { + aturi: event.aturi.clone(), + cid: event.cid.clone(), + site_url: format!("/events/{}", event.cid), // Will be overridden by EventView if available + collection, // Set collection for legacy detection + + name: event_details.name.to_string(), + description: Some(event_details.description.to_string()).filter(|s| !s.is_empty()), + description_short: Some(event_details.description.to_string()).filter(|s| !s.is_empty()).map(|desc| { + // Truncate description for short version + if desc.len() > 200 { + format!("{}...", &desc[..200]) + } else { + desc + } + }), + + organizer_did: event.did.clone(), + organizer_display_name: hydrated.creator_handle + .as_ref() + .map(|h| h.handle.clone()) + .unwrap_or_else(|| event.did.clone()), + + starts_at_machine: event_details.starts_at.as_ref().map(|dt| dt.to_rfc3339()), + starts_at_human: event_details.starts_at.as_ref().map(|dt| { + dt.format("%e %B %Y %I:%M %P %Z").to_string() + }), + ends_at_machine: event_details.ends_at.as_ref().map(|dt| dt.to_rfc3339()), + ends_at_human: event_details.ends_at.as_ref().map(|dt| { + dt.format("%e %B %Y %I:%M %P %Z").to_string() + }), + + mode: event_details.mode.as_ref().map(|m| m.to_string()), + status: event_details.status.as_ref().map(|s| s.to_string()), + address_display: event_details.locations.first().and_then(|loc| { + // Extract address from first location if available + match loc { + crate::atproto::lexicon::community::lexicon::calendar::event::EventLocation::Address(addr) => { + Some(format!("{:?}", addr)) // Convert Address to string representation + }, + crate::atproto::lexicon::community::lexicon::calendar::event::EventLocation::Uri(named_uri) => { + match named_uri { + crate::atproto::lexicon::community::lexicon::calendar::event::NamedUri::Current { name, .. } => { + name.clone() + } + } + }, + _ => None, // For other location types (Geo, Fsq, Hthree) + } + }), + + count_going: hydrated.rsvp_counts.as_ref().map(|c| c.going as u32).unwrap_or(0), + count_interested: hydrated.rsvp_counts.as_ref().map(|c| c.interested as u32).unwrap_or(0), + count_not_going: hydrated.rsvp_counts.as_ref().map(|c| c.not_going as u32).unwrap_or(0), + + role: None, // Will be set below + links: event_details.uris.iter().map(|uri| { + match uri { + crate::atproto::lexicon::community::lexicon::calendar::event::EventLink::Current { uri, name } => { + (uri.clone(), name.clone()) + } + } + }).collect(), + }; + + // Use EventView data if available for better formatting (overrides the basic data) + if let Some(ref event_view) = hydrated.event_view { + template_event.name = event_view.name.clone(); + template_event.description = event_view.description.clone(); + template_event.description_short = event_view.description_short.clone(); + template_event.organizer_display_name = event_view.organizer_display_name.clone(); + template_event.starts_at_machine = event_view.starts_at_machine.clone(); + template_event.starts_at_human = event_view.starts_at_human.clone(); + template_event.ends_at_machine = event_view.ends_at_machine.clone(); + template_event.ends_at_human = event_view.ends_at_human.clone(); + template_event.mode = event_view.mode.clone(); + template_event.status = event_view.status.clone(); + template_event.address_display = event_view.address_display.clone(); + template_event.count_going = event_view.count_going; + template_event.count_interested = event_view.count_interested; + template_event.count_not_going = event_view.count_notgoing; // Note: EventView uses "count_notgoing" but template expects "count_not_going" + template_event.links = event_view.links.clone(); + template_event.site_url = event_view.site_url.clone(); + template_event.collection = event_view.collection.clone(); // Override collection from EventView + } + + // Determine user's role/relationship to this event + template_event.role = determine_user_role(&hydrated.event, user_did, pool).await; + + template_event +} + +/// Determine the user's role in relation to an event +async fn determine_user_role( + event: &crate::storage::event::model::Event, + user_did: Option<&str>, + pool: &StoragePool, +) -> Option { + // If no user context, no role + let user_did = user_did?; + + // Check if user is the organizer + if event.did == user_did { + return Some("organizer".to_string()); + } + + // Look up user's RSVP status for this event + match get_user_rsvp(pool, &event.aturi, user_did).await { + Ok(Some(rsvp_status)) => Some(rsvp_status), + Ok(None) => None, // No RSVP found + Err(_) => { + // Log error but don't fail the whole request + tracing::warn!("Failed to lookup RSVP status for user {} and event {}", user_did, event.aturi); + None + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/http/middleware_timezone.rs b/src/http/middleware_timezone.rs new file mode 100644 index 0000000..b807b41 --- /dev/null +++ b/src/http/middleware_timezone.rs @@ -0,0 +1,307 @@ +use axum::{ + extract::{ConnectInfo, Request}, + http::{HeaderMap, StatusCode}, + middleware::Next, + response::Response, +}; +use reqwest; +use serde::Deserialize; +use std::{ + collections::HashMap, + net::{IpAddr, SocketAddr}, + sync::{Arc, Mutex}, + time::{Duration, SystemTime}, +}; +use tokio::time::timeout; + +// Extension pour stocker le timezone détecté +#[derive(Clone, Debug)] +pub struct DetectedTimezone { + pub timezone: String, + pub source: String, + pub coordinates: Option<(f64, f64)>, +} + +// Cache simple pour éviter trop d'appels API +type TimezoneCache = Arc>>; + +// Configuration du middleware +#[derive(Clone)] +pub struct TimezoneConfig { + pub cache_duration: Duration, + pub request_timeout: Duration, + pub fallback_timezone: String, +} + +impl Default for TimezoneConfig { + fn default() -> Self { + Self { + cache_duration: Duration::from_secs(3600), // 1 heure + request_timeout: Duration::from_secs(3), + fallback_timezone: "UTC".to_string(), + } + } +} + +// Middleware principal +pub async fn timezone_middleware( + ConnectInfo(addr): ConnectInfo, + headers: HeaderMap, + mut request: Request, + next: Next, +) -> Result { + let config = TimezoneConfig::default(); + let cache: TimezoneCache = Arc::new(Mutex::new(HashMap::new())); + + let detected_timezone = detect_timezone_with_cache( + addr.ip(), + &headers, + &config, + &cache, + ).await; + + // Ajouter le timezone détecté aux extensions de la requête + request.extensions_mut().insert(detected_timezone); + + Ok(next.run(request).await) +} + +// Détection avec cache +async fn detect_timezone_with_cache( + ip: IpAddr, + headers: &HeaderMap, + config: &TimezoneConfig, + cache: &TimezoneCache, +) -> DetectedTimezone { + // Vérifier le cache d'abord + if let Ok(cache_lock) = cache.lock() { + if let Some((cached_tz, timestamp)) = cache_lock.get(&ip) { + if timestamp.elapsed().unwrap_or(Duration::MAX) < config.cache_duration { + return cached_tz.clone(); + } + } + } + + // Priorité 1: Header personnalisé + if let Some(tz_header) = headers.get("x-timezone") { + if let Ok(timezone) = tz_header.to_str() { + let detected = DetectedTimezone { + timezone: timezone.to_string(), + source: "header".to_string(), + coordinates: None, + }; + cache_timezone(cache, ip, detected.clone()); + return detected; + } + } + + // Priorité 2: Détection par IP (seulement pour les IPs publiques) + if !is_private_ip(ip) { + if let Some(detected) = detect_timezone_by_ip(ip, config).await { + cache_timezone(cache, ip, detected.clone()); + return detected; + } + } + + // Fallback + let fallback = DetectedTimezone { + timezone: config.fallback_timezone.clone(), + source: "fallback".to_string(), + coordinates: None, + }; + + cache_timezone(cache, ip, fallback.clone()); + fallback +} + +// Détection par IP avec plusieurs services +async fn detect_timezone_by_ip(ip: IpAddr, config: &TimezoneConfig) -> Option { + // Essayer WorldTimeAPI en premier + if let Some(tz) = get_worldtime_timezone_ip(ip, config.request_timeout).await { + return Some(DetectedTimezone { + timezone: tz, + source: "worldtimeapi".to_string(), + coordinates: None, + }); + } + + // Fallback sur ipapi.co + if let Some((tz, coords)) = get_ipapi_detailed(ip, config.request_timeout).await { + return Some(DetectedTimezone { + timezone: tz, + source: "ipapi".to_string(), + coordinates: Some(coords), + }); + } + + // Dernier fallback sur ip-api.com + get_ip_api_detailed(ip, config.request_timeout).await +} + +// Services API individuels +async fn get_worldtime_timezone_ip(ip: IpAddr, timeout_duration: Duration) -> Option { + #[derive(Deserialize)] + struct WorldTimeResponse { + timezone: String, + } + + let client = reqwest::Client::new(); + let response: WorldTimeResponse = timeout( + timeout_duration, + client.get("http://worldtimeapi.org/api/ip").send() + ) + .await + .ok()? + .ok()? + .json() + .await + .ok()?; + + Some(response.timezone) +} + +async fn get_ipapi_detailed(ip: IpAddr, timeout_duration: Duration) -> Option<(String, (f64, f64))> { + #[derive(Deserialize)] + struct IpapiResponse { + timezone: Option, + latitude: Option, + longitude: Option, + } + + let url = format!("https://ipapi.co/{}/json/", ip); + let client = reqwest::Client::new(); + + let response: IpapiResponse = timeout( + timeout_duration, + client.get(&url).send() + ) + .await + .ok()? + .ok()? + .json() + .await + .ok()?; + + match (response.timezone, response.latitude, response.longitude) { + (Some(tz), Some(lat), Some(lon)) => Some((tz, (lat, lon))), + _ => None, + } +} + +async fn get_ip_api_detailed(ip: IpAddr, timeout_duration: Duration) -> Option { + #[derive(Deserialize)] + struct IpApiResponse { + timezone: Option, + lat: Option, + lon: Option, + status: String, + } + + let url = format!("http://ip-api.com/json/{}?fields=timezone,lat,lon,status", ip); + let client = reqwest::Client::new(); + + let response: IpApiResponse = timeout( + timeout_duration, + client.get(&url).send() + ) + .await + .ok()? + .ok()? + .json() + .await + .ok()?; + + if response.status == "success" { + Some(DetectedTimezone { + timezone: response.timezone.unwrap_or_else(|| "UTC".to_string()), + source: "ip-api".to_string(), + coordinates: match (response.lat, response.lon) { + (Some(lat), Some(lon)) => Some((lat, lon)), + _ => None, + }, + }) + } else { + None + } +} + +// Utilitaires +fn is_private_ip(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(ipv4) => { + ipv4.is_private() || ipv4.is_loopback() || ipv4.is_link_local() + } + IpAddr::V6(ipv6) => { + ipv6.is_loopback() || ipv6.is_multicast() + } + } +} + +fn cache_timezone(cache: &TimezoneCache, ip: IpAddr, timezone: DetectedTimezone) { + if let Ok(mut cache_lock) = cache.lock() { + cache_lock.insert(ip, (timezone, SystemTime::now())); + } +} + +// Extension trait pour extraire facilement le timezone dans les handlers +pub trait TimezoneExtractor { + fn timezone(&self) -> Option<&DetectedTimezone>; + fn timezone_string(&self) -> String; +} + +impl TimezoneExtractor for axum::extract::Request { + fn timezone(&self) -> Option<&DetectedTimezone> { + self.extensions().get::() + } + + fn timezone_string(&self) -> String { + self.timezone() + .map(|tz| tz.timezone.clone()) + .unwrap_or_else(|| "UTC".to_string()) + } +} + +// Exemple d'utilisation dans un handler +use axum::{extract::Request, response::Json}; + +pub async fn example_handler(request: Request) -> Json { + let timezone_info = request.timezone().cloned().unwrap_or_else(|| DetectedTimezone { + timezone: "Unknown".to_string(), + source: "error".to_string(), + coordinates: None, + }); + + Json(serde_json::json!({ + "timezone": timezone_info.timezone, + "source": timezone_info.source, + "coordinates": timezone_info.coordinates, + "message": format!("Votre timezone détecté: {}", timezone_info.timezone) + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{ + body::Body, + http::{HeaderMap, Request}, + middleware, + routing::get, + Router, + }; + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + + #[tokio::test] + async fn test_timezone_detection() { + let app = Router::new() + .route("/test", get(example_handler)) + .layer(middleware::from_fn(timezone_middleware)) + .into_make_service_with_connect_info::(); + + // Test avec une IP publique simulée + let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)), 80); + + // Ici vous pourriez ajouter des tests plus détaillés + assert!(true); // Placeholder test + } +} \ No newline at end of file diff --git a/src/i18n/template_helpers.rs b/src/i18n/template_helpers.rs index fc6b5df..a1a88d5 100644 --- a/src/i18n/template_helpers.rs +++ b/src/i18n/template_helpers.rs @@ -200,6 +200,7 @@ fn extract_locale_from_template_context(state: &minijinja::State, default: &Lang } /// Extract locale from function kwargs, falling back to default +#[allow(dead_code)] fn extract_locale_from_kwargs(kwargs: &Kwargs, default: &LanguageIdentifier) -> Result { if let Ok(Some(locale_str)) = kwargs.get::>("locale") { locale_str.parse::() diff --git a/templates/event_list.en-us.incl.html b/templates/event_list.en-us.incl.html index 9a22b11..d8360ca 100644 --- a/templates/event_list.en-us.incl.html +++ b/templates/event_list.en-us.incl.html @@ -13,13 +13,13 @@
- {%- if role == 'going' -%} + {%- if event.role == 'going' -%} {{ t("status-going") }} - {%- elif role == 'interested' -%} + {%- elif event.role == 'interested' -%} {{ t("status-interested") }} - {%- elif role == 'notgoing' -%} + {%- elif event.role == 'notgoing' -%} {{ t("status-not-going") }} - {%- elif role == 'organizer' -%} + {%- elif event.role == 'organizer' -%} {{ t("status-organizer") }} {%- else -%} {{ t("status-unknown") }} diff --git a/templates/event_list.fr-ca.incl.html b/templates/event_list.fr-ca.incl.html index ebcd9fe..871e693 100644 --- a/templates/event_list.fr-ca.incl.html +++ b/templates/event_list.fr-ca.incl.html @@ -13,13 +13,13 @@
- {%- if role == 'going' -%} + {%- if event.role == 'going' -%} {{ t("status-going") }} - {%- elif role == 'interested' -%} + {%- elif event.role == 'interested' -%} {{ t("status-interested") }} - {%- elif role == 'notgoing' -%} + {%- elif event.role == 'notgoing' -%} {{ t("status-not-going") }} - {%- elif role == 'organizer' -%} + {%- elif event.role == 'organizer' -%} {{ t("status-organizer") }} {%- else -%} {{ t("status-unknown") }} diff --git a/templates/filter_events_results.incl.html b/templates/filter_events_results.incl.html index 0ca0058..2677f05 100644 --- a/templates/filter_events_results.incl.html +++ b/templates/filter_events_results.incl.html @@ -48,80 +48,7 @@ {% if events and events|length > 0 %} -
- {% for event in events %} -
-
-
-

- {{ event.name }} -

-

- {% if event.starts_at %} - - - - {{ event.starts_at | date(format="%B %d, %Y at %H:%M") }} - {% endif %} - - {% if event.location %} - - - - {{ event.location.name }} - {% endif %} -

- - {% if event.description %} -

{{ event.description | truncate(200) }}

- {% endif %} - -
- - - - - {{ event.creator_handle | default(event.creator_did) }} - - - {% if event.mode %} - - {{ t(event.mode_i18n_key) | default(event.mode) }} - - {% endif %} - - {% if event.status %} - - {{ t(event.status_i18n_key) | default(event.status) }} - - {% endif %} - - {% if event.rsvp_count %} - - {{ t("filter-rsvp-count", count=event.rsvp_count) }} - - {% endif %} -
-
-
-
-
- - {{ t("filter-view-event") }} - - {% if event.can_rsvp %} - - {% endif %} -
-
-
- {% endfor %} -
+ {% include 'event_list.' + current_locale + '.incl.html' %} {% if pagination %} diff --git a/test_i18n_integration.rs b/test_i18n_integration.rs deleted file mode 100644 index e69de29..0000000