The smokesignal.events web application

refactor: event attendees page improvements

+342 -141
+5
migrations/20251108000000_rsvp_status_index.sql
···
··· 1 + -- Add index for efficient RSVP queries by event, status, and time ordering 2 + -- This supports queries that filter by event_aturi and status, then order by updated_at DESC 3 + -- Enables fast pagination and counting without full table scans 4 + CREATE INDEX IF NOT EXISTS idx_rsvps_event_status_updated 5 + ON rsvps (event_aturi, status, updated_at DESC);
+59 -21
src/http/handle_view_event.rs
··· 22 }; 23 use crate::storage::event::event_exists; 24 use crate::storage::event::event_get; 25 - use crate::storage::event::get_event_rsvps_with_validation; 26 use crate::storage::event::get_going_rsvp_count; 27 use crate::storage::event::get_recent_going_rsvps; 28 use crate::storage::event::get_user_rsvp_status_and_validation; 29 use crate::storage::event::rsvp_get_by_event_and_did; ··· 660 // Extract event details for display 661 let details = crate::storage::event::extract_event_details(&event); 662 663 - // Fetch up to 500 recent RSVPs with handles (all statuses) 664 - let rsvps = get_event_rsvps_with_validation(&ctx.web_context.pool, &aturi, None) 665 - .await 666 - .unwrap_or_default(); 667 668 - // Extract DIDs (limited to 500) 669 - let dids: Vec<String> = rsvps.iter().take(500).map(|(did, _, _)| did.clone()).collect(); 670 671 // Perform batch lookup for handles 672 - let handle_profiles = handles_by_did(&ctx.web_context.pool, dids.clone()) 673 .await 674 .unwrap_or_default(); 675 676 - // Create RsvpDisplay objects with handles 677 - let attendees: Vec<RsvpDisplay> = rsvps 678 - .into_iter() 679 - .filter_map(|(did, status, validated_at)| { 680 - handle_profiles.get(&did).map(|profile| RsvpDisplay { 681 - did: did.clone(), 682 - handle: profile.handle.clone(), 683 - status, 684 - verified: validated_at.is_some(), 685 }) 686 - }) 687 - .collect(); 688 689 Ok(( 690 StatusCode::OK, ··· 698 handle_slug, 699 event_rkey, 700 collection => collection.clone(), 701 - attendees, 702 - attendee_count => attendees.len(), 703 }, 704 ), 705 )
··· 22 }; 23 use crate::storage::event::event_exists; 24 use crate::storage::event::event_get; 25 use crate::storage::event::get_going_rsvp_count; 26 + use crate::storage::event::get_grouped_event_rsvps; 27 use crate::storage::event::get_recent_going_rsvps; 28 use crate::storage::event::get_user_rsvp_status_and_validation; 29 use crate::storage::event::rsvp_get_by_event_and_did; ··· 660 // Extract event details for display 661 let details = crate::storage::event::extract_event_details(&event); 662 663 + // Fetch all RSVPs grouped by status in a single optimized query 664 + // This replaces 6 separate queries (3 for data + 3 for counts) with 1 query 665 + let grouped_data = match get_grouped_event_rsvps(&ctx.web_context.pool, &aturi).await { 666 + Ok(data) => data, 667 + Err(err) => { 668 + tracing::error!("Error fetching grouped RSVPs: {:?}", err); 669 + return contextual_error!( 670 + ctx.web_context, 671 + ctx.language, 672 + error_template, 673 + default_context, 674 + ViewEventError::EventNotFound(err.to_string()), 675 + StatusCode::INTERNAL_SERVER_ERROR 676 + ); 677 + } 678 + }; 679 680 + // Extract DIDs from all groups for batch lookup 681 + let mut all_dids: Vec<String> = Vec::new(); 682 + all_dids.extend(grouped_data.going.iter().map(|(did, _, _)| did.clone())); 683 + all_dids.extend(grouped_data.interested.iter().map(|(did, _, _)| did.clone())); 684 + all_dids.extend(grouped_data.notgoing.iter().map(|(did, _, _)| did.clone())); 685 686 // Perform batch lookup for handles 687 + let handle_profiles = handles_by_did(&ctx.web_context.pool, all_dids) 688 .await 689 .unwrap_or_default(); 690 691 + // Helper closure to create RsvpDisplay objects 692 + let create_attendees = |rsvps: Vec<(String, String, Option<chrono::DateTime<chrono::Utc>>)>| -> Vec<RsvpDisplay> { 693 + rsvps 694 + .into_iter() 695 + .filter_map(|(did, status, validated_at)| { 696 + handle_profiles.get(&did).map(|profile| RsvpDisplay { 697 + did: did.clone(), 698 + handle: profile.handle.clone(), 699 + status, 700 + verified: validated_at.is_some(), 701 + }) 702 }) 703 + .collect() 704 + }; 705 + 706 + // Create separate attendee lists 707 + let going_attendees = create_attendees(grouped_data.going); 708 + let interested_attendees = create_attendees(grouped_data.interested); 709 + let notgoing_attendees = create_attendees(grouped_data.notgoing); 710 + 711 + // Extract counts from grouped data 712 + let going_total = grouped_data.going_total; 713 + let interested_total = grouped_data.interested_total; 714 + let notgoing_total = grouped_data.notgoing_total; 715 + 716 + // Calculate total count from database totals 717 + let total_count = going_total + interested_total + notgoing_total; 718 719 Ok(( 720 StatusCode::OK, ··· 728 handle_slug, 729 event_rkey, 730 collection => collection.clone(), 731 + going_attendees, 732 + interested_attendees, 733 + notgoing_attendees, 734 + going_count => going_total, 735 + interested_count => interested_total, 736 + notgoing_count => notgoing_total, 737 + going_displayed => going_attendees.len(), 738 + interested_displayed => interested_attendees.len(), 739 + notgoing_displayed => notgoing_attendees.len(), 740 + total_count, 741 }, 742 ), 743 )
+121
src/storage/event.rs
··· 980 ))) 981 } 982 983 pub(crate) async fn event_list( 984 pool: &StoragePool, 985 page: i64,
··· 980 ))) 981 } 982 983 + /// Grouped RSVP data with limited results per status and total counts 984 + pub struct GroupedRsvpData { 985 + pub going: Vec<(String, String, Option<chrono::DateTime<chrono::Utc>>)>, 986 + pub interested: Vec<(String, String, Option<chrono::DateTime<chrono::Utc>>)>, 987 + pub notgoing: Vec<(String, String, Option<chrono::DateTime<chrono::Utc>>)>, 988 + pub going_total: u32, 989 + pub interested_total: u32, 990 + pub notgoing_total: u32, 991 + } 992 + 993 + /// Fetch RSVPs grouped by status with limits and total counts in a single optimized query 994 + /// 995 + /// This function uses a window function to efficiently: 996 + /// - Limit results per status (going: 500, interested: 200, notgoing: 100) 997 + /// - Order by updated_at DESC for each status 998 + /// - Return total counts for each status 999 + /// - Execute as a single database query with one table scan 1000 + /// 1001 + /// This is significantly more efficient than making 6 separate queries (3 for data + 3 for counts) 1002 + pub async fn get_grouped_event_rsvps( 1003 + pool: &StoragePool, 1004 + event_aturi: &str, 1005 + ) -> Result<GroupedRsvpData, StorageError> { 1006 + // Validate event_aturi is not empty 1007 + if event_aturi.trim().is_empty() { 1008 + return Err(StorageError::UnableToExecuteQuery(sqlx::Error::Protocol( 1009 + "Event URI cannot be empty".into(), 1010 + ))); 1011 + } 1012 + 1013 + let mut tx = pool 1014 + .begin() 1015 + .await 1016 + .map_err(StorageError::CannotBeginDatabaseTransaction)?; 1017 + 1018 + // Single query with window functions for efficient grouped pagination 1019 + // ROW_NUMBER partitions by status and orders by updated_at DESC 1020 + // COUNT(*) OVER gives us the total count per status 1021 + let rows = sqlx::query_as::<_, (String, String, Option<chrono::DateTime<chrono::Utc>>, i64)>( 1022 + r#" 1023 + WITH ranked_rsvps AS ( 1024 + SELECT 1025 + did, 1026 + status, 1027 + validated_at, 1028 + ROW_NUMBER() OVER (PARTITION BY status ORDER BY updated_at DESC) as rn, 1029 + COUNT(*) OVER (PARTITION BY status) as status_count 1030 + FROM rsvps 1031 + WHERE event_aturi = $1 1032 + AND status IN ('going', 'interested', 'notgoing') 1033 + ) 1034 + SELECT 1035 + did, 1036 + status, 1037 + validated_at, 1038 + status_count 1039 + FROM ranked_rsvps 1040 + WHERE (status = 'going' AND rn <= 500) 1041 + OR (status = 'interested' AND rn <= 200) 1042 + OR (status = 'notgoing' AND rn <= 100) 1043 + ORDER BY 1044 + CASE status 1045 + WHEN 'going' THEN 1 1046 + WHEN 'interested' THEN 2 1047 + WHEN 'notgoing' THEN 3 1048 + END, 1049 + rn 1050 + "#, 1051 + ) 1052 + .bind(event_aturi) 1053 + .fetch_all(tx.as_mut()) 1054 + .await 1055 + .map_err(StorageError::UnableToExecuteQuery)?; 1056 + 1057 + tx.commit() 1058 + .await 1059 + .map_err(StorageError::CannotCommitDatabaseTransaction)?; 1060 + 1061 + // Separate rows by status and extract counts 1062 + let mut going = Vec::new(); 1063 + let mut interested = Vec::new(); 1064 + let mut notgoing = Vec::new(); 1065 + let mut going_total: u32 = 0; 1066 + let mut interested_total: u32 = 0; 1067 + let mut notgoing_total: u32 = 0; 1068 + 1069 + for (did, status, validated_at, count) in rows { 1070 + let count_u32 = count as u32; 1071 + match status.as_str() { 1072 + "going" => { 1073 + if going_total == 0 { 1074 + going_total = count_u32; 1075 + } 1076 + going.push((did, status, validated_at)); 1077 + } 1078 + "interested" => { 1079 + if interested_total == 0 { 1080 + interested_total = count_u32; 1081 + } 1082 + interested.push((did, status, validated_at)); 1083 + } 1084 + "notgoing" => { 1085 + if notgoing_total == 0 { 1086 + notgoing_total = count_u32; 1087 + } 1088 + notgoing.push((did, status, validated_at)); 1089 + } 1090 + _ => {} // Ignore unknown statuses 1091 + } 1092 + } 1093 + 1094 + Ok(GroupedRsvpData { 1095 + going, 1096 + interested, 1097 + notgoing, 1098 + going_total, 1099 + interested_total, 1100 + notgoing_total, 1101 + }) 1102 + } 1103 + 1104 pub(crate) async fn event_list( 1105 pool: &StoragePool, 1106 page: i64,
+95 -105
templates/en-us/view_event.common.html
··· 1 {% macro verified_check(attendee) -%} 2 {%- if attendee.verified -%} 3 - <span class="icon"><i class="fa fa-certificate"></i></span> 4 - {%- else -%} 5 - {%- if attendee.status == "going" -%} 6 - <span class="icon"><i class="fas fa-circle-check"></i></span> 7 - {%- elif attendee.status == "interested" -%} 8 - <span class="icon"><i class="fas fa-circle-question"></i></span> 9 - {%- elif attendee.status == "notgoing" -%} 10 - <span class="icon"><i class="fas fa-circle-xmark"></i></span> 11 - {%- endif -%} 12 {%- endif -%} 13 {%- endmacro %} 14 <style> 15 /* ============================================ 16 MINIMAL CUSTOM STYLES 17 Most styling uses Bulma CSS 1.0+ utilities 18 ============================================ */ 19 20 /* Header Image - 16:9 Aspect Ratio */ ··· 36 border-radius: 50%; 37 object-fit: cover; 38 flex-shrink: 0; 39 - border: 3px solid hsl(0, 0%, 86%); 40 } 41 42 - @media (prefers-color-scheme: dark) { 43 - .organizer-avatar { 44 - border-color: hsl(0, 0%, 29%); 45 - } 46 } 47 48 - /* RSVP Section - Solid Background */ 49 - .rsvp-section { 50 - background: hsl(0, 0%, 96%); 51 } 52 53 - /* RSVP State Colors - Border-left indicators */ 54 - .rsvp-message { border-left: 4px solid hsl(0, 0%, 86%); } 55 - .rsvp-message.is-success { border-left-color: #48c78e; background-color: hsl(138, 76%, 97%); } 56 - .rsvp-message.is-info { border-left-color: #3e8ed0; background-color: hsl(217, 71%, 97%); } 57 - .rsvp-message.is-warning { border-left-color: #ffe08a; background-color: hsl(48, 100%, 96%); } 58 - .rsvp-message.is-danger { border-left-color: #f14668; background-color: hsl(348, 86%, 97%); } 59 - .rsvp-message.is-neutral { border-left-color: #667eea; background-color: hsl(0, 0%, 96%); } 60 61 - /* Location Badge Colors */ 62 .location-badge.is-virtual { 63 - background: hsl(199, 89%, 92%); 64 - color: hsl(199, 84%, 35%); 65 } 66 .location-badge.is-in-person { 67 - background: hsl(48, 96%, 89%); 68 - color: hsl(25, 75%, 28%); 69 } 70 .location-badge.is-hybrid { 71 - background: hsl(270, 80%, 91%); 72 - color: hsl(270, 67%, 40%); 73 } 74 75 - /* Link Hover Effect */ 76 .event-link:hover { 77 - background: hsl(0, 0%, 96%); 78 - border-color: #667eea; 79 transform: translateX(2px); 80 } 81 82 - /* Address Link Brand Colors */ 83 - .address-link .fa-apple { color: hsl(0, 0%, 21%); } 84 - .address-link .fa-google { color: #4285f4; } 85 86 - /* Link Icon Background */ 87 .link-icon-bg { 88 - background: hsl(0, 0%, 100%); 89 } 90 91 - /* Section Divider */ 92 .section-divider { 93 - border-color: hsl(0, 0%, 86%); 94 } 95 96 - /* Address Box Border */ 97 .address-box-border { 98 - border-left-color: #667eea; 99 } 100 101 - /* Dark Mode Overrides */ 102 - @media (prefers-color-scheme: dark) { 103 - .rsvp-section { 104 - background: hsl(0, 0%, 14%); 105 - } 106 - 107 - .rsvp-message { border-left-color: hsl(0, 0%, 29%); background-color: hsl(0, 0%, 14%); } 108 - .rsvp-message.is-success { border-left-color: #48c78e; background-color: hsl(138, 45%, 15%); } 109 - .rsvp-message.is-info { border-left-color: #3e8ed0; background-color: hsl(217, 45%, 15%); } 110 - .rsvp-message.is-warning { border-left-color: #ffe08a; background-color: hsl(48, 60%, 15%); } 111 - .rsvp-message.is-danger { border-left-color: #f14668; background-color: hsl(348, 60%, 15%); } 112 - .rsvp-message.is-neutral { border-left-color: #667eea; background-color: hsl(0, 0%, 14%); } 113 - 114 - .location-badge.is-virtual { 115 - background: hsl(199, 70%, 25%); 116 - color: hsl(199, 89%, 85%); 117 - } 118 - .location-badge.is-in-person { 119 - background: hsl(48, 75%, 25%); 120 - color: hsl(48, 96%, 85%); 121 - } 122 - .location-badge.is-hybrid { 123 - background: hsl(270, 60%, 30%); 124 - color: hsl(270, 80%, 85%); 125 - } 126 127 - .event-link:hover { 128 - background: hsl(0, 0%, 14%); 129 - } 130 131 - .address-link .fa-apple { color: hsl(0, 0%, 86%); } 132 - 133 - .link-icon-bg { 134 - background: hsl(0, 0%, 21%); 135 - } 136 137 - .section-divider { 138 - border-color: hsl(0, 0%, 29%); 139 - } 140 141 - .address-box-border { 142 - border-left-color: #8b9fff; 143 - } 144 } 145 146 /* Event Header */ ··· 190 {% endif %} 191 <div style="flex: 1; min-width: 0;"> 192 <h1 class="title is-1 mb-2">{%- autoescape false -%}{{- event.name -}}{%- endautoescape -%}</h1> 193 - <p class="subtitle has-text-grey mb-3"> 194 Organized by <a href="{{ base }}/{{ event.organizer_did }}" class="has-text-link has-text-weight-semibold">@{{ event.organizer_display_name }}</a> 195 </p> 196 </div> ··· 204 {% if event.is_in_progress %} 205 <div class="notification is-success is-light p-4"> 206 <span class="icon-text"> 207 - <span class="icon"><i class="fas fa-circle"></i></span> 208 <strong>Event is in progress</strong> 209 </span> 210 </div> ··· 212 <div class="notification is-warning is-light p-4"> 213 <span class="icon-text"> 214 <span class="icon"><i class="fas fa-clock"></i></span> 215 - <strong>Starts in {{ event.starts_in_hours }} {% if event.starts_in_hours == 1 %}hour{% else %}hours{% endif %}</strong> 216 </span> 217 </div> 218 {% endif %} ··· 231 232 <!-- Status --> 233 {% if event.status and event.status != "scheduled" %} 234 - <div class="is-flex mb-3 p-3 has-background-light" style="border-radius: 6px; gap: 0.75rem;"> 235 <span class="icon has-text-link"> 236 {% if event.status == "planned" %}<i class="fas fa-info-circle"></i> 237 {% elif event.status == "cancelled" %}<i class="fas fa-ban"></i> ··· 241 {% endif %} 242 </span> 243 <div class="is-flex-grow-1"> 244 - <div class="is-size-7 has-text-grey is-uppercase mb-1">Status</div> 245 {% if event.status == "planned" %}<span class="tag is-warning">Planned</span> 246 {% elif event.status == "cancelled" %}<span class="tag is-danger">Canceled</span> 247 {% elif event.status == "rescheduled" %}<span class="tag is-info">Rescheduled</span> ··· 253 {% endif %} 254 255 <!-- Start Time --> 256 - <div class="is-flex mb-3 p-3 has-background-light" style="border-radius: 6px; gap: 0.75rem;"> 257 <span class="icon has-text-link"><i class="fas fa-calendar-day"></i></span> 258 <div class="is-flex-grow-1"> 259 - <div class="is-size-7 has-text-grey is-uppercase mb-1">Starts</div> 260 {% if event.starts_at_human %} 261 <div class="has-text-weight-semibold">{{ event.starts_at_date }}</div> 262 - <div class="has-text-grey-dark is-size-6 mt-1"> 263 {{ event.starts_at_time }} 264 - <span class="icon is-small has-text-grey" title="{% if current_handle %}Times shown in your configured timezone{% else %}Times shown in event timezone{% endif %}"> 265 {% if current_handle %}<i class="fas fa-user-clock"></i> 266 {% else %}<i class="fas fa-globe"></i>{% endif %} 267 </span> 268 </div> 269 - {% if event.starts_in_hours and event.starts_in_hours < 24 %} 270 - <div class="notification is-warning is-light p-2 mt-2 is-size-7"> 271 - <i class="fas fa-hourglass-half"></i> Starts in {{ event.starts_in_hours }} {% if event.starts_in_hours == 1 %}hour{% else %}hours{% endif %} 272 - </div> 273 - {% endif %} 274 {% else %} 275 - <div class="has-text-grey is-italic">No start time set</div> 276 {% endif %} 277 </div> 278 </div> 279 280 <!-- End Time --> 281 {% if event.ends_at_human %} 282 - <div class="is-flex p-3 has-background-light" style="border-radius: 6px; gap: 0.75rem;"> 283 <span class="icon has-text-link"><i class="fas fa-clock"></i></span> 284 <div class="is-flex-grow-1"> 285 - <div class="is-size-7 has-text-grey is-uppercase mb-1">Ends</div> 286 {% if event.ends_at_date and event.ends_at_date != event.starts_at_date %} 287 <div class="has-text-weight-semibold">{{ event.ends_at_date }}</div> 288 - <div class="has-text-grey-dark is-size-6 mt-1"> 289 {{ event.ends_at_time }} 290 - <span class="icon is-small has-text-grey" title="{% if current_handle %}Times shown in your configured timezone{% else %}Times shown in event timezone{% endif %}"> 291 {% if current_handle %}<i class="fas fa-user-clock"></i> 292 {% else %}<i class="fas fa-globe"></i>{% endif %} 293 </span> ··· 295 {% else %} 296 <div class="has-text-weight-semibold"> 297 {{ event.ends_at_time }} 298 - <span class="icon is-small has-text-grey" title="{% if current_handle %}Times shown in your configured timezone{% else %}Times shown in event timezone{% endif %}"> 299 {% if current_handle %}<i class="fas fa-user-clock"></i> 300 {% else %}<i class="fas fa-globe"></i>{% endif %} 301 </span> ··· 323 {% set going_count = event.count_going | default(0) %} 324 {% set rsvp_count = recent_going_rsvps|length %} 325 {% if going_count == 0 %} 326 - <p class="has-text-grey">No one is going to the event yet. <a href="{{ base }}/{{ handle_slug }}/{{ event_rkey }}/attendees" class="has-text-weight-semibold">View all attendees</a></p> 327 {% elif rsvp_count > 0 %} 328 <p> 329 {% if going_count == 1 %} ··· 422 {% if user_rsvp_status == "going" %}<i class="fas fa-check-circle"></i> 423 {% elif user_rsvp_status == "interested" %}<i class="fas fa-star"></i>{% endif %} 424 </span> 425 - <span><strong>You're {{ user_rsvp_status }}</strong> • <span class="has-text-grey"><i class="fas fa-crown has-text-warning"></i> RSVP signed by organizer</span></span> 426 </span> 427 </div> 428 <div class="rsvp-controls is-flex" style="gap: 1rem;"> ··· 444 <div class="rsvp-message box p-3 mb-3 is-success"> 445 <span class="icon-text"> 446 <span class="icon"><i class="fas fa-ticket"></i></span> 447 - <span><strong>Organizer accepted your RSVP</strong> • <span class="has-text-grey"><i class="fas fa-exclamation-circle has-text-warning"></i> Confirm your attendance</span></span> 448 </span> 449 </div> 450
··· 1 {% macro verified_check(attendee) -%} 2 {%- if attendee.verified -%} 3 + <span class="icon"><i class="fa fa-circle-check"></i></span> 4 {%- endif -%} 5 {%- endmacro %} 6 <style> 7 /* ============================================ 8 MINIMAL CUSTOM STYLES 9 Most styling uses Bulma CSS 1.0+ utilities 10 + Dark mode handled automatically by Bulma 11 ============================================ */ 12 13 /* Header Image - 16:9 Aspect Ratio */ ··· 29 border-radius: 50%; 30 object-fit: cover; 31 flex-shrink: 0; 32 + border: 3px solid hsl(var(--bulma-scheme-h), var(--bulma-scheme-s), var(--bulma-border-l)); 33 } 34 35 + /* RSVP Section - Uses Bulma scheme colors */ 36 + .rsvp-section { 37 + background: hsl(var(--bulma-scheme-h), var(--bulma-scheme-s), var(--bulma-scheme-main-bis-l)); 38 } 39 40 + /* RSVP State Colors - Border-left indicators with proper dark mode support */ 41 + .rsvp-message { 42 + border-left: 4px solid hsl(var(--bulma-scheme-h), var(--bulma-scheme-s), var(--bulma-border-l)); 43 + background-color: hsl(var(--bulma-scheme-h), var(--bulma-scheme-s), var(--bulma-background-l)); 44 } 45 46 + .rsvp-message.is-success { 47 + border-left-color: hsl(var(--bulma-success-h), var(--bulma-success-s), var(--bulma-success-l)); 48 + } 49 + 50 + .rsvp-message.is-info { 51 + border-left-color: hsl(var(--bulma-info-h), var(--bulma-info-s), var(--bulma-info-l)); 52 + } 53 + 54 + .rsvp-message.is-warning { 55 + border-left-color: hsl(var(--bulma-warning-h), var(--bulma-warning-s), var(--bulma-warning-l)); 56 + } 57 58 + .rsvp-message.is-danger { 59 + border-left-color: hsl(var(--bulma-danger-h), var(--bulma-danger-s), var(--bulma-danger-l)); 60 + } 61 + 62 + .rsvp-message.is-neutral { 63 + border-left-color: hsl(var(--bulma-link-h), var(--bulma-link-s), var(--bulma-link-l)); 64 + } 65 + 66 + /* Location Badge Colors - Using Bulma color system with semantic colors */ 67 .location-badge.is-virtual { 68 + background: hsl(var(--bulma-info-h), var(--bulma-info-s), var(--bulma-info-90-l)); 69 + color: hsl(var(--bulma-info-h), var(--bulma-info-s), var(--bulma-info-20-l)); 70 } 71 + 72 .location-badge.is-in-person { 73 + background: hsl(var(--bulma-warning-h), var(--bulma-warning-s), var(--bulma-warning-90-l)); 74 + color: hsl(var(--bulma-warning-h), var(--bulma-warning-s), var(--bulma-warning-20-l)); 75 } 76 + 77 .location-badge.is-hybrid { 78 + background: hsl(var(--bulma-link-h), var(--bulma-link-s), var(--bulma-link-90-l)); 79 + color: hsl(var(--bulma-link-h), var(--bulma-link-s), var(--bulma-link-20-l)); 80 } 81 82 + /* Link Hover Effect - Uses scheme colors for dark mode compatibility */ 83 + .event-link { 84 + transition: all 0.2s; 85 + } 86 + 87 .event-link:hover { 88 + background: hsl(var(--bulma-scheme-h), var(--bulma-scheme-s), calc(var(--bulma-scheme-main-l) + var(--bulma-hover-background-l-delta))); 89 + border-color: hsl(var(--bulma-link-h), var(--bulma-link-s), var(--bulma-link-l)); 90 transform: translateX(2px); 91 } 92 93 + /* Address Link Brand Colors - Adjusted for dark mode */ 94 + .address-link .fa-apple { 95 + color: hsl(var(--bulma-scheme-h), var(--bulma-scheme-s), var(--bulma-text-strong-l)); 96 + } 97 + 98 + .address-link .fa-google { 99 + color: #4285f4; /* Google brand color - keep consistent */ 100 + } 101 102 + /* Link Icon Background - Uses scheme colors */ 103 .link-icon-bg { 104 + background: hsl(var(--bulma-scheme-h), var(--bulma-scheme-s), var(--bulma-scheme-main-l)); 105 } 106 107 + /* Section Divider - Uses Bulma border color */ 108 .section-divider { 109 + border-color: hsl(var(--bulma-scheme-h), var(--bulma-scheme-s), var(--bulma-border-l)); 110 } 111 112 + /* Address Box Border - Uses link color for emphasis */ 113 .address-box-border { 114 + border-left-color: hsl(var(--bulma-link-h), var(--bulma-link-s), var(--bulma-link-l)); 115 } 116 117 + /* Event Details Box - Better contrast for dark mode */ 118 + .event-detail-box { 119 + background: hsl(var(--bulma-scheme-h), var(--bulma-scheme-s), var(--bulma-background-l)); 120 + border-radius: 6px; 121 + } 122 123 + .event-detail-label { 124 + color: hsl(var(--bulma-scheme-h), var(--bulma-scheme-s), var(--bulma-text-weak-l)); 125 + } 126 127 + .event-detail-time { 128 + color: hsl(var(--bulma-scheme-h), var(--bulma-scheme-s), var(--bulma-text-l)); 129 + } 130 131 + .event-detail-icon { 132 + color: hsl(var(--bulma-scheme-h), var(--bulma-scheme-s), var(--bulma-text-weak-l)); 133 + } 134 135 + /* RSVP Message Secondary Text - Adapts to dark mode */ 136 + .rsvp-secondary-text { 137 + color: hsl(var(--bulma-scheme-h), var(--bulma-scheme-s), var(--bulma-text-weak-l)); 138 + opacity: 0.9; 139 } 140 141 /* Event Header */ ··· 185 {% endif %} 186 <div style="flex: 1; min-width: 0;"> 187 <h1 class="title is-1 mb-2">{%- autoescape false -%}{{- event.name -}}{%- endautoescape -%}</h1> 188 + <p class="subtitle mb-3"> 189 Organized by <a href="{{ base }}/{{ event.organizer_did }}" class="has-text-link has-text-weight-semibold">@{{ event.organizer_display_name }}</a> 190 </p> 191 </div> ··· 199 {% if event.is_in_progress %} 200 <div class="notification is-success is-light p-4"> 201 <span class="icon-text"> 202 + <span class="icon has-text-danger"><i class="fas fa-circle"></i></span> 203 <strong>Event is in progress</strong> 204 </span> 205 </div> ··· 207 <div class="notification is-warning is-light p-4"> 208 <span class="icon-text"> 209 <span class="icon"><i class="fas fa-clock"></i></span> 210 + <strong>{% if event.starts_in_hours < 1 %}Starting soon{% elif event.starts_in_hours == 1 %}Starting in 1 hour{% else %}Starting in {{ event.starts_in_hours }} hours{% endif %}</strong> 211 </span> 212 </div> 213 {% endif %} ··· 226 227 <!-- Status --> 228 {% if event.status and event.status != "scheduled" %} 229 + <div class="is-flex mb-3 p-3 event-detail-box" style="gap: 0.75rem;"> 230 <span class="icon has-text-link"> 231 {% if event.status == "planned" %}<i class="fas fa-info-circle"></i> 232 {% elif event.status == "cancelled" %}<i class="fas fa-ban"></i> ··· 236 {% endif %} 237 </span> 238 <div class="is-flex-grow-1"> 239 + <div class="is-size-7 event-detail-label is-uppercase mb-1">Status</div> 240 {% if event.status == "planned" %}<span class="tag is-warning">Planned</span> 241 {% elif event.status == "cancelled" %}<span class="tag is-danger">Canceled</span> 242 {% elif event.status == "rescheduled" %}<span class="tag is-info">Rescheduled</span> ··· 248 {% endif %} 249 250 <!-- Start Time --> 251 + <div class="is-flex mb-3 p-3 event-detail-box" style="gap: 0.75rem;"> 252 <span class="icon has-text-link"><i class="fas fa-calendar-day"></i></span> 253 <div class="is-flex-grow-1"> 254 + <div class="is-size-7 event-detail-label is-uppercase mb-1">Starts</div> 255 {% if event.starts_at_human %} 256 <div class="has-text-weight-semibold">{{ event.starts_at_date }}</div> 257 + <div class="event-detail-time is-size-6 mt-1"> 258 {{ event.starts_at_time }} 259 + <span class="icon is-small event-detail-icon" title="{% if current_handle %}Times shown in your configured timezone{% else %}Times shown in event timezone{% endif %}"> 260 {% if current_handle %}<i class="fas fa-user-clock"></i> 261 {% else %}<i class="fas fa-globe"></i>{% endif %} 262 </span> 263 </div> 264 {% else %} 265 + <div class="event-detail-label is-italic">No start time set</div> 266 {% endif %} 267 </div> 268 </div> 269 270 <!-- End Time --> 271 {% if event.ends_at_human %} 272 + <div class="is-flex p-3 event-detail-box" style="gap: 0.75rem;"> 273 <span class="icon has-text-link"><i class="fas fa-clock"></i></span> 274 <div class="is-flex-grow-1"> 275 + <div class="is-size-7 event-detail-label is-uppercase mb-1">Ends</div> 276 {% if event.ends_at_date and event.ends_at_date != event.starts_at_date %} 277 <div class="has-text-weight-semibold">{{ event.ends_at_date }}</div> 278 + <div class="event-detail-time is-size-6 mt-1"> 279 {{ event.ends_at_time }} 280 + <span class="icon is-small event-detail-icon" title="{% if current_handle %}Times shown in your configured timezone{% else %}Times shown in event timezone{% endif %}"> 281 {% if current_handle %}<i class="fas fa-user-clock"></i> 282 {% else %}<i class="fas fa-globe"></i>{% endif %} 283 </span> ··· 285 {% else %} 286 <div class="has-text-weight-semibold"> 287 {{ event.ends_at_time }} 288 + <span class="icon is-small event-detail-icon" title="{% if current_handle %}Times shown in your configured timezone{% else %}Times shown in event timezone{% endif %}"> 289 {% if current_handle %}<i class="fas fa-user-clock"></i> 290 {% else %}<i class="fas fa-globe"></i>{% endif %} 291 </span> ··· 313 {% set going_count = event.count_going | default(0) %} 314 {% set rsvp_count = recent_going_rsvps|length %} 315 {% if going_count == 0 %} 316 + <p class="event-detail-label">No one is going to the event yet. <a href="{{ base }}/{{ handle_slug }}/{{ event_rkey }}/attendees" class="has-text-weight-semibold">View all attendees</a></p> 317 {% elif rsvp_count > 0 %} 318 <p> 319 {% if going_count == 1 %} ··· 412 {% if user_rsvp_status == "going" %}<i class="fas fa-check-circle"></i> 413 {% elif user_rsvp_status == "interested" %}<i class="fas fa-star"></i>{% endif %} 414 </span> 415 + <span><strong>You're {{ user_rsvp_status }}</strong> • <span class="rsvp-secondary-text"><i class="fas fa-crown has-text-warning"></i> RSVP signed by organizer</span></span> 416 </span> 417 </div> 418 <div class="rsvp-controls is-flex" style="gap: 1rem;"> ··· 434 <div class="rsvp-message box p-3 mb-3 is-success"> 435 <span class="icon-text"> 436 <span class="icon"><i class="fas fa-ticket"></i></span> 437 + <span><strong>Organizer accepted your RSVP</strong> • <span class="rsvp-secondary-text"><i class="fas fa-exclamation-circle has-text-warning"></i> Confirm your attendance</span></span> 438 </span> 439 </div> 440
+62 -15
templates/en-us/view_event_attendees.common.html
··· 1 {% macro verified_check(attendee) -%} 2 {%- if attendee.verified -%} 3 - <span class="icon"><i class="fa fa-certificate"></i></span> 4 - {%- else -%} 5 - {%- if attendee.status == "going" -%} 6 - <span class="icon"><i class="fas fa-circle-check"></i></span> 7 - {%- elif attendee.status == "interested" -%} 8 - <span class="icon"><i class="fas fa-circle-question"></i></span> 9 - {%- elif attendee.status == "notgoing" -%} 10 - <span class="icon"><i class="fas fa-circle-xmark"></i></span> 11 - {%- endif -%} 12 {%- endif -%} 13 {%- endmacro %} 14 <section class="section"> 15 <div class="container"> 16 <div class="content"> 17 <h1 class="title is-3">Event Attendees</h1> 18 - {% if attendee_count > 0 %} 19 <p class="subtitle is-5"> 20 - <strong>{{ attendee_count }}</strong> {% if attendee_count == 1 %}person has{% else %}people have{% endif 21 - %} RSPV'd to this event. 22 </p> 23 <div class="grid is-col-min-12 has-text-centered"> 24 - {% for attendee in attendees %} 25 <span class="cell"> 26 {{ verified_check(attendee) }} 27 - <a href="{ base }}/{{ attendee.did }}"> 28 {% if attendee.handle %} 29 @{{ attendee.handle }} 30 {% else %} ··· 34 </span> 35 {% endfor %} 36 </div> 37 {% else %} 38 <div class="notification is-info is-light"> 39 - <p>No one is attending this event yet.</p> 40 </div> 41 {% endif %} 42 <div class="m-3">
··· 1 {% macro verified_check(attendee) -%} 2 {%- if attendee.verified -%} 3 + <span class="icon"><i class="fa fa-circle-check"></i></span> 4 {%- endif -%} 5 {%- endmacro %} 6 <section class="section"> 7 <div class="container"> 8 <div class="content"> 9 <h1 class="title is-3">Event Attendees</h1> 10 + {% if total_count > 0 %} 11 <p class="subtitle is-5"> 12 + <strong>{{ total_count }}</strong> {% if total_count == 1 %}person has{% else %}people have{% endif 13 + %} RSVP'd to this event. 14 + </p> 15 + 16 + {% if going_count > 0 %} 17 + <h2 class="title is-4 mt-5">Going</h2> 18 + {% if going_displayed < going_count %} 19 + <p class="help has-text-grey mb-3"> 20 + Showing {{ going_displayed }} most recent of {{ going_count }} total 21 + </p> 22 + {% endif %} 23 + <div class="grid is-col-min-12 has-text-centered"> 24 + {% for attendee in going_attendees %} 25 + <span class="cell"> 26 + {{ verified_check(attendee) }} 27 + <a href="{{ base }}/{{ attendee.did }}"> 28 + {% if attendee.handle %} 29 + @{{ attendee.handle }} 30 + {% else %} 31 + <span class="has-text-grey">{{ attendee.did }}</span> 32 + {% endif %} 33 + </a> 34 + </span> 35 + {% endfor %} 36 + </div> 37 + {% endif %} 38 + 39 + {% if interested_count > 0 %} 40 + <h2 class="title is-4 mt-5">Interested</h2> 41 + {% if interested_displayed < interested_count %} 42 + <p class="help has-text-grey mb-3"> 43 + Showing {{ interested_displayed }} most recent of {{ interested_count }} total 44 </p> 45 + {% endif %} 46 <div class="grid is-col-min-12 has-text-centered"> 47 + {% for attendee in interested_attendees %} 48 <span class="cell"> 49 {{ verified_check(attendee) }} 50 + <a href="{{ base }}/{{ attendee.did }}"> 51 {% if attendee.handle %} 52 @{{ attendee.handle }} 53 {% else %} ··· 57 </span> 58 {% endfor %} 59 </div> 60 + {% endif %} 61 + 62 + {% if notgoing_count > 0 %} 63 + <h2 class="title is-4 mt-5">Not Going</h2> 64 + {% if notgoing_displayed < notgoing_count %} 65 + <p class="help has-text-grey mb-3"> 66 + Showing {{ notgoing_displayed }} most recent of {{ notgoing_count }} total 67 + </p> 68 + {% endif %} 69 + <div class="grid is-col-min-12 has-text-centered"> 70 + {% for attendee in notgoing_attendees %} 71 + <span class="cell"> 72 + {{ verified_check(attendee) }} 73 + <a href="{{ base }}/{{ attendee.did }}"> 74 + {% if attendee.handle %} 75 + @{{ attendee.handle }} 76 + {% else %} 77 + <span class="has-text-grey">{{ attendee.did }}</span> 78 + {% endif %} 79 + </a> 80 + </span> 81 + {% endfor %} 82 + </div> 83 + {% endif %} 84 {% else %} 85 <div class="notification is-info is-light"> 86 + <p>No one has RSVP'd to this event yet.</p> 87 </div> 88 {% endif %} 89 <div class="m-3">