Heavily customized version of smokesignal - https://whtwnd.com/kayrozen.com/3lpwe4ymowg2t
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Second pass to the implementation of bookmarks.

crudely works, but needs more testing and polish.

kayrozen 9c4fc5ca 38616e08

+3037 -394
+13 -6
i18n/en-us/bookmarks.ftl
··· 5 5 timeline-view = Timeline 6 6 calendar-view = Calendar 7 7 filter-by-tags = Filter by Tags 8 - calendar-navigation = Calendar 9 8 remove-bookmark = Remove Bookmark 10 9 confirm-remove-bookmark = Are you sure you want to remove this bookmark? 11 10 add-to-calendar = Add to Calendar ··· 15 14 bookmark-tags = Tags (comma-separated) 16 15 bookmark-calendar-name = Calendar Name 17 16 bookmark-calendar-description = Description (optional) 18 - make-calendar-public = Make this calendar public 19 17 bookmarked-on = Bookmarked on {$date} 20 - ends-at = Ends at {$time} 21 18 22 19 # Enhanced calendar management 23 20 bookmark-calendars = Custom Calendars 24 21 create-calendar = Create Calendar 25 22 create-bookmark-calendar = Create Custom Calendar 26 - create-new-calendar = Create New Calendar 27 23 calendar-name = Calendar Name 28 24 calendar-name-placeholder = e.g. Summer Festivals, Work Events 29 25 calendar-name-help = Choose a descriptive name for your calendar 30 26 calendar-tags = Tags 31 27 add-tag-placeholder = Add a tag and press Enter 32 - calendar-tags-help = Add tags to organize your calendar 33 28 calendar-description-placeholder = What types of events will you collect? 34 29 make-calendar-public = Make this calendar public 35 30 public-calendar-help = Public calendars appear on your profile and can be discovered by others ··· 40 35 no-public-calendars = No public calendars yet 41 36 export-calendar = Export Calendar 42 37 share-calendar = Share Calendar 43 - events = events 44 38 created = Created on 45 39 calendar-updated = Calendar updated successfully 46 40 calendar-created = Calendar created successfully 47 41 event-added-to-calendar = Event added to calendar 48 42 43 + # Timeline view 44 + back-to-calendars = Back to Calendars 45 + calendar-navigation = Calendar 46 + start-bookmarking-events = Start bookmarking events to see them here 47 + 49 48 # Tag management 50 49 calendar-tags-help = Add multiple tags to organize this calendar (press Enter or comma to add) 51 50 tag-suggestions = Suggested tags ··· 61 60 error-max-tags = Maximum 10 tags allowed per calendar 62 61 error-empty-tag = Empty tags are not allowed 63 62 error-tag-too-long = Tag too long (maximum 50 characters) 63 + 64 + # Calendar management 65 + manage-calendars = Manage Calendars 66 + view-calendar = View Calendar 67 + delete-calendar = Delete Calendar 68 + confirm-delete-calendar = Are you sure you want to delete this calendar? 69 + no-calendars-yet = No calendars yet 70 + create-first-calendar-help = Create your first calendar to organize bookmarked events by tags
+16
i18n/en-us/common.ftl
··· 311 311 # Location labels 312 312 location = Location 313 313 314 + # Navigation for bookmark integration 315 + nav-events-calendars = Events & Calendars 316 + nav-browse-events = Browse Events 317 + nav-my-calendars = My Calendars 318 + nav-all-bookmarks = All Bookmarks 319 + 320 + # Calendar days and months 321 + mon = Mon 322 + tue = Tue 323 + wed = Wed 324 + thu = Thu 325 + fri = Fri 326 + sat = Sat 327 + sun = Sun 328 + june = June 329 + 314 330 # iCal export translations 315 331 ical-untitled-event = Untitled Event 316 332 ical-event-description-fallback = Event organized by {$organizer} via smokesignal.events
+13 -6
i18n/fr-ca/bookmarks.ftl
··· 5 5 timeline-view = Vue chronologique 6 6 calendar-view = Vue calendrier 7 7 filter-by-tags = Filtrer par étiquettes 8 - calendar-navigation = Navigation 9 8 remove-bookmark = Retirer le marque-page 10 9 confirm-remove-bookmark = Êtes-vous sûr·e de vouloir retirer ce marque-page? 11 10 add-to-calendar = Ajouter au calendrier 12 - create-new-calendar = Créer un nouveau calendrier 13 11 bookmark-success = Événement marqué avec succès! 14 12 no-bookmarked-events = Aucun événement marqué trouvé 15 13 bookmark-tags = Étiquettes (séparées par des virgules) 16 14 bookmark-calendar-name = Nom du calendrier 17 15 bookmark-calendar-description = Description (optionnelle) 18 - make-calendar-public = Rendre ce calendrier public 19 16 bookmarked-on = Marqué le {$date} 20 - ends-at = Se termine à {$time} 21 17 22 18 # Gestion avancée des calendriers 23 19 bookmark-calendars = Calendriers personnalisés ··· 29 25 calendar-name-help = Choisissez un nom descriptif pour votre calendrier 30 26 calendar-tags = Étiquettes 31 27 add-tag-placeholder = Ajouter une étiquette et appuyer sur Entrée 32 - calendar-tags-help = Ajoutez des étiquettes pour organiser votre calendrier 33 28 calendar-description-placeholder = Quels types d'événements allez-vous collecter ? 34 29 make-calendar-public = Rendre ce calendrier public 35 30 public-calendar-help = Les calendriers publics apparaissent sur votre profil et peuvent être découverts par d'autres ··· 40 35 no-public-calendars = Pas encore de calendriers publics 41 36 export-calendar = Exporter le calendrier 42 37 share-calendar = Partager le calendrier 43 - events = événements 44 38 created = Créé le 45 39 calendar-updated = Calendrier mis à jour avec succès 46 40 calendar-created = Calendrier créé avec succès 47 41 event-added-to-calendar = Événement ajouté au calendrier 48 42 43 + # Vue chronologique 44 + back-to-calendars = Retour aux calendriers 45 + calendar-navigation = Navigation 46 + start-bookmarking-events = Commencez à marquer des événements pour les voir ici 47 + 49 48 # Gestion des étiquettes 50 49 calendar-tags-help = Ajoutez plusieurs étiquettes pour organiser ce calendrier (appuyez sur Entrée ou virgule pour ajouter) 51 50 tag-suggestions = Étiquettes suggérées ··· 61 60 error-max-tags = Maximum 10 étiquettes autorisées par calendrier 62 61 error-empty-tag = Les étiquettes vides ne sont pas autorisées 63 62 error-tag-too-long = Étiquette trop longue (maximum 50 caractères) 63 + 64 + # Gestion des calendriers 65 + manage-calendars = Gérer les calendriers 66 + view-calendar = Voir le calendrier 67 + delete-calendar = Supprimer le calendrier 68 + confirm-delete-calendar = Êtes-vous sûr·e de vouloir supprimer ce calendrier ? 69 + no-calendars-yet = Aucun calendrier pour l'instant 70 + create-first-calendar-help = Créez votre premier calendrier pour organiser les événements marqués par étiquettes
+16
i18n/fr-ca/common.ftl
··· 330 330 # Étiquettes de lieu 331 331 location = Lieu 332 332 333 + # Navigation pour l'intégration des signets 334 + nav-events-calendars = Événements et calendriers 335 + nav-browse-events = Parcourir les événements 336 + nav-my-calendars = Mes calendriers 337 + nav-all-bookmarks = Tous les signets 338 + 339 + # Jours et mois du calendrier 340 + mon = Lun 341 + tue = Mar 342 + wed = Mer 343 + thu = Jeu 344 + fri = Ven 345 + sat = Sam 346 + sun = Dim 347 + june = Juin 348 + 333 349 # Traductions d'exportation iCal 334 350 ical-untitled-event = Événement sans titre 335 351 ical-event-description-fallback = Événement organisé par {$organizer} via smokesignal.events
+72
lexicons/community_lexicon_bookmarks_getActorBookmarks_schema.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "community.lexicon.bookmarks.getActorBookmarks", 4 + "description": "Get bookmarks for a specific actor", 5 + "defs": { 6 + "main": { 7 + "type": "query", 8 + "description": "Get bookmarks for a specific actor", 9 + "parameters": { 10 + "type": "params", 11 + "required": ["actor"], 12 + "properties": { 13 + "actor": { 14 + "type": "string", 15 + "format": "at-identifier", 16 + "description": "The actor whose bookmarks to retrieve" 17 + }, 18 + "limit": { 19 + "type": "integer", 20 + "minimum": 1, 21 + "maximum": 100, 22 + "default": 50, 23 + "description": "Maximum number of bookmarks to return" 24 + }, 25 + "cursor": { 26 + "type": "string", 27 + "description": "Pagination cursor" 28 + } 29 + } 30 + }, 31 + "output": { 32 + "encoding": "application/json", 33 + "schema": { 34 + "type": "object", 35 + "required": ["bookmarks"], 36 + "properties": { 37 + "bookmarks": { 38 + "type": "array", 39 + "items": { 40 + "type": "ref", 41 + "ref": "#bookmarkView" 42 + } 43 + }, 44 + "cursor": { 45 + "type": "string" 46 + } 47 + } 48 + } 49 + } 50 + }, 51 + "bookmarkView": { 52 + "type": "object", 53 + "required": ["uri", "value"], 54 + "properties": { 55 + "uri": { 56 + "type": "string", 57 + "format": "at-uri", 58 + "description": "AT-URI of the bookmark record" 59 + }, 60 + "value": { 61 + "type": "ref", 62 + "ref": "community.lexicon.bookmarks.bookmark" 63 + }, 64 + "indexedAt": { 65 + "type": "string", 66 + "format": "datetime", 67 + "description": "When the bookmark was indexed" 68 + } 69 + } 70 + } 71 + } 72 + }
+34
lexicons/community_lexicon_bookmarks_schema.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "community.lexicon.bookmarks.bookmark", 4 + "description": "A bookmark record for events and other content", 5 + "defs": { 6 + "main": { 7 + "type": "record", 8 + "key": "any", 9 + "record": { 10 + "type": "object", 11 + "required": ["subject", "createdAt"], 12 + "properties": { 13 + "subject": { 14 + "type": "string", 15 + "format": "uri", 16 + "description": "The URI of the subject being bookmarked" 17 + }, 18 + "createdAt": { 19 + "type": "string", 20 + "format": "datetime", 21 + "description": "When the bookmark was created" 22 + }, 23 + "tags": { 24 + "type": "array", 25 + "items": { 26 + "type": "string" 27 + }, 28 + "description": "Optional tags for categorizing the bookmark" 29 + } 30 + } 31 + } 32 + } 33 + } 34 + }
lexicons/dev_tunn_plaque_bookmarks_schema.json

This is a binary file and will not be displayed.

+4
migrations/20250619000000_remove_bookmark_fkey.sql
··· 1 + -- Remove foreign key constraint from event_bookmarks 2 + -- This allows bookmarking events that aren't locally cached 3 + 4 + ALTER TABLE event_bookmarks DROP CONSTRAINT event_bookmarks_event_aturi_fkey;
scripts/publish_lexicons.sh

This is a binary file and will not be displayed.

scripts/register_bookmark_lexicon.sh

This is a binary file and will not be displayed.

+69 -5
src/atproto/client.rs
··· 8 8 use crate::http::middleware_auth::WebSession; 9 9 use anyhow::Result; 10 10 use atrium_api::com::atproto::repo::{ 11 - create_record, put_record, list_records, 11 + create_record, put_record, list_records, delete_record, 12 12 }; 13 13 use atrium_api::types::{ 14 14 string::{AtIdentifier, Did, Handle, Nsid, RecordKey, Cid}, ··· 25 25 pub collection: String, 26 26 #[serde(skip_serializing_if = "Option::is_none", default, rename = "rkey")] 27 27 pub record_key: Option<String>, 28 - pub validate: bool, 28 + #[serde(skip_serializing_if = "Option::is_none")] 29 + pub validate: Option<bool>, 29 30 pub record: T, 30 31 #[serde(skip_serializing_if = "Option::is_none", default, rename = "swapCommit")] 31 32 pub swap_commit: Option<String>, ··· 39 40 pub collection: String, 40 41 #[serde(rename = "rkey")] 41 42 pub record_key: String, 42 - pub validate: bool, 43 + #[serde(skip_serializing_if = "Option::is_none")] 44 + pub validate: Option<bool>, 43 45 pub record: T, 44 46 #[serde(skip_serializing_if = "Option::is_none", default, rename = "swapCommit")] 45 47 pub swap_commit: Option<String>, ··· 47 49 pub swap_record: Option<String>, 48 50 } 49 51 52 + /// Request structure for deleting AT Protocol records 53 + #[derive(Debug, Serialize, Deserialize, Clone)] 54 + pub struct DeleteRecordRequest { 55 + pub repo: String, 56 + pub collection: String, 57 + #[serde(rename = "rkey")] 58 + pub record_key: String, 59 + #[serde(skip_serializing_if = "Option::is_none", default, rename = "swapRecord")] 60 + pub swap_record: Option<String>, 61 + #[serde(skip_serializing_if = "Option::is_none", default, rename = "swapCommit")] 62 + pub swap_commit: Option<String>, 63 + } 64 + 50 65 /// Parameters for listing records 51 66 #[derive(Debug, Serialize, Deserialize, Clone)] 52 67 pub struct ListRecordsParams { ··· 138 153 repo, 139 154 collection, 140 155 rkey, 141 - validate: Some(request.validate), 156 + validate: request.validate, // Use the optional validate field directly 142 157 record: record_unknown, 143 158 swap_commit, 144 159 }; ··· 213 228 repo, 214 229 collection, 215 230 rkey, 216 - validate: Some(request.validate), 231 + validate: request.validate, // Use the optional validate field directly 217 232 record: record_unknown, 218 233 swap_commit, 219 234 swap_record, ··· 300 315 records, 301 316 cursor: response.data.cursor, 302 317 }) 318 + } 319 + 320 + /// Delete a record using Atrium API 321 + pub async fn delete_record( 322 + &self, 323 + web_session: &WebSession, 324 + request: DeleteRecordRequest, 325 + ) -> Result<(), anyhow::Error> { 326 + // Get the authenticated Atrium client 327 + let agent = self.atrium_manager 328 + .get_atproto_client(&web_session.session_group) 329 + .await 330 + .map_err(|e| anyhow::anyhow!("Failed to get AT Protocol client: {}", e))?; 331 + 332 + // Convert our request to Atrium format 333 + let repo = if request.repo.starts_with("did:") { 334 + let did = Did::new(request.repo.clone()) 335 + .map_err(|e| anyhow::anyhow!("Invalid DID format: {}", e))?; 336 + AtIdentifier::from(did) 337 + } else { 338 + let handle = Handle::new(request.repo.clone()) 339 + .map_err(|e| anyhow::anyhow!("Invalid handle format: {}", e))?; 340 + AtIdentifier::from(handle) 341 + }; 342 + 343 + let collection = Nsid::new(request.collection.clone()) 344 + .map_err(|e| anyhow::anyhow!("Invalid collection NSID: {}", e))?; 345 + 346 + let rkey = RecordKey::new(request.record_key) 347 + .map_err(|e| anyhow::anyhow!("Invalid record key: {}", e))?; 348 + 349 + // Build the input data 350 + let input_data = delete_record::InputData { 351 + repo, 352 + collection, 353 + rkey, 354 + swap_commit: None, 355 + swap_record: None, 356 + }; 357 + 358 + // Make the API call 359 + agent.api.com.atproto.repo.delete_record(input_data.into()) 360 + .await 361 + .map_err(|e| { 362 + tracing::error!("AT Protocol delete_record failed: {:?}", e); 363 + anyhow::anyhow!("Delete record failed: {}", e) 364 + })?; 365 + 366 + Ok(()) 303 367 } 304 368 }
+10 -6
src/atproto/lexicon/community_lexicon_bookmarks.rs
··· 4 4 pub const BOOKMARK_NSID: &str = "community.lexicon.bookmarks.bookmark"; 5 5 pub const GET_BOOKMARKS_NSID: &str = "community.lexicon.bookmarks.getActorBookmarks"; 6 6 7 - #[derive(Debug, Serialize, Deserialize, Clone)] 7 + #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] 8 8 pub struct Bookmark { 9 - pub subject: String, // Event AT-URI (e.g., at://did:plc:xyz/community.lexicon.calendar.event/abc123) 10 - pub tags: Vec<String>, // Tags are required for organization 11 - #[serde(rename = "createdAt")] 9 + pub subject: String, // Event AT-URI (e.g., at://did:plc:xyz/community.lexicon.calendar.event/abc123) 10 + 11 + #[serde(rename = "createdAt", with = "crate::atproto::datetime::datetime_format")] 12 12 pub created_at: DateTime<Utc>, 13 + 14 + #[serde(skip_serializing_if = "Option::is_none")] 15 + pub tags: Option<Vec<String>>, // Tags are optional according to the official lexicon 13 16 } 14 17 15 18 #[derive(Debug, Serialize, Deserialize)] 16 19 pub struct GetActorBookmarksParams { 17 - pub actor: String, 20 + #[serde(skip_serializing_if = "Option::is_none")] 21 + pub tags: Option<Vec<String>>, 18 22 #[serde(skip_serializing_if = "Option::is_none")] 19 23 pub cursor: Option<String>, 20 24 #[serde(skip_serializing_if = "Option::is_none")] ··· 23 27 24 28 #[derive(Debug, Serialize, Deserialize)] 25 29 pub struct GetActorBookmarksResponse { 26 - pub bookmarks: Vec<BookmarkRecord>, // Returns full bookmark records with AT-URIs 30 + pub bookmarks: Vec<Bookmark>, // Returns bookmark records directly per the official lexicon 27 31 #[serde(skip_serializing_if = "Option::is_none")] 28 32 pub cursor: Option<String>, 29 33 }
+1 -1
src/atproto/lexicon/mod.rs
··· 1 1 pub mod com_atproto_repo; 2 - mod community_lexicon_bookmarks; 2 + pub mod community_lexicon_bookmarks; 3 3 mod community_lexicon_calendar_event; 4 4 mod community_lexicon_calendar_rsvp; 5 5 pub mod community_lexicon_location;
+20 -23
src/http/handle_bookmark_calendars.rs
··· 1 1 use axum::{ 2 2 extract::{Path}, 3 - response::{Html, IntoResponse}, 3 + response::{Html, IntoResponse, Redirect}, 4 4 http::StatusCode, 5 5 Json, 6 6 }; ··· 43 43 let language_clone = ctx.language.clone(); 44 44 let renderer = create_renderer!(ctx.web_context.clone(), language_clone, hx_boosted, false); 45 45 46 - // For now, return empty calendars list 46 + // Fetch user's calendars from the database 47 + let calendars = match crate::storage::bookmark_calendars::get_by_user(&ctx.web_context.pool, &current_handle.did, true).await { 48 + Ok(calendars) => calendars, 49 + Err(e) => { 50 + error!("Failed to fetch calendars for user {}: {}", current_handle.did, e); 51 + Vec::new() 52 + } 53 + }; 54 + 47 55 let template_context = template_context! { 48 - calendars => Vec::<BookmarkCalendar>::new(), 56 + calendars => calendars, 49 57 user_did => current_handle.did, 50 58 }; 51 59 ··· 105 113 Ok(created_calendar) => { 106 114 info!("Successfully created calendar {} for user {}", created_calendar.calendar_id, current_handle.did); 107 115 108 - let html = format!( 109 - r#"<div class="notification is-success"> 110 - <p>Calendar "{}" created successfully!</p> 111 - </div>"#, 112 - created_calendar.name 113 - ); 114 - 115 - Ok(Html(html).into_response()) 116 + // Return a simple success response - JavaScript will handle refreshing the page 117 + Ok(Html("Calendar created successfully".to_string()).into_response()) 116 118 } 117 119 Err(e) => { 118 120 error!("Failed to create calendar: {}", e); ··· 140 142 return Ok((StatusCode::FORBIDDEN, "Access denied".to_string()).into_response()); 141 143 } 142 144 143 - let template_context = template_context! { 144 - calendar => calendar, 145 - is_owner => calendar.did == current_handle.did, 146 - events => Vec::<String>::new(), // TODO: Fetch events for this calendar 147 - }; 148 - 149 - let html = renderer.render_template( 150 - "bookmark_calendar_view", 151 - template_context, 152 - ctx.current_handle.as_ref(), 153 - &format!("/bookmark-calendars/{}", calendar_id) 145 + // Redirect to the bookmark timeline with the calendar's tags as filters 146 + let tags_param = calendar.tags.join(","); 147 + let redirect_url = format!("/bookmarks?tags={}&calendar_name={}", 148 + urlencoding::encode(&tags_param), 149 + urlencoding::encode(&calendar.name) 154 150 ); 155 - Ok(Html(html).into_response()) 151 + 152 + Ok(axum::response::Redirect::to(&redirect_url).into_response()) 156 153 } 157 154 Ok(None) => { 158 155 Ok((StatusCode::NOT_FOUND, "Calendar not found".to_string()).into_response())
+248 -33
src/http/handle_bookmark_events.rs
··· 4 4 http::StatusCode, 5 5 }; 6 6 use axum_htmx::HxBoosted; 7 - use chrono::Datelike; 7 + use chrono::{Datelike, DateTime, TimeZone, Timelike}; 8 + use chrono_tz::Tz; 8 9 use minijinja::context as template_context; 9 10 use serde::{Deserialize, Serialize}; 10 11 use std::sync::Arc; ··· 13 14 use crate::create_renderer; 14 15 use crate::http::context::UserRequestContext; 15 16 use crate::http::errors::WebError; 17 + use crate::i18n::fluent_loader::LOCALES; 16 18 use crate::services::event_bookmarks::EventBookmarkService; 19 + use crate::storage::event_bookmarks::BookmarkedEvent; 20 + use fluent_templates::Loader; 21 + 22 + // Date formatting function with French locale support 23 + fn format_datetime_for_locale(dt: &DateTime<Tz>, locale: Option<&fluent_templates::LanguageIdentifier>) -> String { 24 + // Check if we're in French locale 25 + let is_french = locale 26 + .map(|l| l.language.as_str() == "fr") 27 + .unwrap_or(false); 28 + 29 + if is_french { 30 + // French format 31 + let months_fr = [ 32 + "janvier", "février", "mars", "avril", "mai", "juin", 33 + "juillet", "août", "septembre", "octobre", "novembre", "décembre" 34 + ]; 35 + let month_name = months_fr.get((dt.month() - 1) as usize).unwrap_or(&""); 36 + format!("{} {} {} à {}:{:02}", 37 + dt.day(), 38 + month_name, 39 + dt.year(), 40 + dt.hour(), 41 + dt.minute() 42 + ) 43 + } else { 44 + // English format 45 + dt.format("%B %-d, %Y at %-I:%M %p").to_string() 46 + } 47 + } 48 + 49 + // Enhanced BookmarkedEvent with timezone-aware formatting 50 + #[derive(Serialize)] 51 + struct BookmarkedEventWithTz { 52 + #[serde(flatten)] 53 + event: BookmarkedEvent, 54 + starts_at_human: Option<String>, 55 + ends_at_human: Option<String>, 56 + } 57 + 58 + fn process_bookmarked_events_with_timezone(events: Vec<BookmarkedEvent>, tz: &Tz, locale: Option<&fluent_templates::LanguageIdentifier>) -> Vec<BookmarkedEventWithTz> { 59 + events.into_iter().map(|event| { 60 + let starts_at_human = event.event_details.starts_at.as_ref().map(|dt| { 61 + let dt_with_tz = dt.with_timezone(tz); 62 + format_datetime_for_locale(&dt_with_tz, locale) 63 + }); 64 + 65 + let ends_at_human = event.event_details.ends_at.as_ref().map(|dt| { 66 + let dt_with_tz = dt.with_timezone(tz); 67 + format_datetime_for_locale(&dt_with_tz, locale) 68 + }); 69 + 70 + BookmarkedEventWithTz { 71 + event, 72 + starts_at_human, 73 + ends_at_human, 74 + } 75 + }).collect() 76 + } 17 77 18 78 #[derive(Deserialize)] 19 79 pub struct BookmarkEventParams { ··· 47 107 ) -> Result<impl IntoResponse, WebError> { 48 108 let current_handle = ctx.auth.require(&ctx.web_context.config.destination_key, "/bookmarks")?; 49 109 110 + // Debug logging to track tag parsing 111 + info!("Raw tags parameter: {:?}", params.tags); 112 + 50 113 let tags: Vec<String> = params 51 114 .tags 52 115 .unwrap_or_default() ··· 54 117 .map(|s| s.trim().to_string()) 55 118 .filter(|s| !s.is_empty()) 56 119 .collect(); 120 + 121 + info!("Parsed tags: {:?}", tags); 57 122 58 123 // Validate tags 59 124 if tags.len() > 10 { 60 - return Ok((StatusCode::BAD_REQUEST, "Maximum 10 tags allowed".to_string()).into_response()); 125 + let error_message = LOCALES.lookup(&ctx.language.0, "error-max-tags"); 126 + return Ok((StatusCode::BAD_REQUEST, error_message).into_response()); 61 127 } 62 128 63 129 for tag in &tags { 64 130 if tag.len() > 50 { 65 - return Ok((StatusCode::BAD_REQUEST, "Tag too long (maximum 50 characters)".to_string()).into_response()); 131 + let error_message = LOCALES.lookup(&ctx.language.0, "error-tag-too-long"); 132 + return Ok((StatusCode::BAD_REQUEST, error_message).into_response()); 66 133 } 67 134 } 68 135 ··· 71 138 Arc::new(ctx.web_context.atrium_oauth_manager.clone()), 72 139 ); 73 140 141 + // First check if the event is already bookmarked 74 142 match bookmark_service 75 - .bookmark_event(&current_handle.did, &params.event_aturi, tags) 143 + .get_bookmark(&current_handle.did, &params.event_aturi) 76 144 .await 77 145 { 78 - Ok(_bookmark) => { 79 - info!("Successfully bookmarked event {} for user {}", params.event_aturi, current_handle.did); 80 - 81 - let html = r#"<div class="notification is-success"> 82 - <p>Event bookmarked successfully!</p> 83 - </div>"#; 84 - 85 - Ok(Html(html.to_string()).into_response()) 146 + Ok(Some(_existing_bookmark)) => { 147 + // Event is already bookmarked, update the tags 148 + // Get session group from the Auth context 149 + let session_group = ctx.auth.1.as_ref() 150 + .map(|oauth_session| oauth_session.session_group.as_str()) 151 + .unwrap_or("unknown"); // Fallback if no session 152 + 153 + match bookmark_service 154 + .update_bookmark_tags(&current_handle.did, &params.event_aturi, tags, session_group) 155 + .await 156 + { 157 + Ok(_) => { 158 + info!("Successfully updated bookmark tags for event {} for user {}", params.event_aturi, current_handle.did); 159 + 160 + let success_message = LOCALES.lookup(&ctx.language.0, "calendar-updated"); 161 + let html = format!(r#"<div class="notification is-success"> 162 + <p>{}</p> 163 + </div>"#, success_message); 164 + 165 + Ok(Html(html).into_response()) 166 + } 167 + Err(e) => { 168 + error!("Failed to update bookmark tags for event {}: {}", params.event_aturi, e); 169 + let error_message = LOCALES.lookup(&ctx.language.0, "error-occurred"); 170 + Ok((StatusCode::INTERNAL_SERVER_ERROR, error_message).into_response()) 171 + } 172 + } 173 + } 174 + Ok(None) => { 175 + // Event is not bookmarked, create new bookmark 176 + // Get session group from the Auth context 177 + let session_group = ctx.auth.1.as_ref() 178 + .map(|oauth_session| oauth_session.session_group.as_str()) 179 + .unwrap_or("unknown"); // Fallback if no session 180 + 181 + match bookmark_service 182 + .bookmark_event(&current_handle.did, &params.event_aturi, tags, session_group) 183 + .await 184 + { 185 + Ok(_bookmark) => { 186 + info!("Successfully bookmarked event {} for user {}", params.event_aturi, current_handle.did); 187 + 188 + let success_message = LOCALES.lookup(&ctx.language.0, "bookmark-success"); 189 + let html = format!(r#"<div class="notification is-success"> 190 + <p>{}</p> 191 + </div>"#, success_message); 192 + 193 + Ok(Html(html).into_response()) 194 + } 195 + Err(e) => { 196 + error!("Failed to bookmark event {}: {}", params.event_aturi, e); 197 + let error_message = LOCALES.lookup(&ctx.language.0, "error-occurred"); 198 + Ok((StatusCode::INTERNAL_SERVER_ERROR, error_message).into_response()) 199 + } 200 + } 86 201 } 87 202 Err(e) => { 88 - error!("Failed to bookmark event {}: {}", params.event_aturi, e); 89 - Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to bookmark event".to_string()).into_response()) 203 + error!("Failed to check existing bookmark for event {}: {}", params.event_aturi, e); 204 + Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to check bookmark status".to_string()).into_response()) 90 205 } 91 206 } 92 207 } ··· 97 212 HxBoosted(hx_boosted): HxBoosted, 98 213 Query(params): Query<CalendarViewParams>, 99 214 ) -> Result<impl IntoResponse, WebError> { 215 + tracing::info!("Starting handle_bookmark_calendar function"); 100 216 let current_handle = ctx.auth.require(&ctx.web_context.config.destination_key, "/bookmarks")?; 101 217 102 218 let view_mode = params.view.as_deref().unwrap_or("timeline"); ··· 133 249 .await 134 250 { 135 251 Ok(paginated_events) => { 252 + // Store count before moving events 253 + let events_count = paginated_events.events.len(); 254 + 255 + // Determine timezone from current user, fallback to UTC 256 + let tz = current_handle.tz.parse::<Tz>().unwrap_or(Tz::UTC); 257 + 258 + // Process events to include timezone-aware date formatting 259 + let processed_events = process_bookmarked_events_with_timezone( 260 + paginated_events.events, 261 + &tz, 262 + Some(&ctx.language.0) 263 + ); 264 + 136 265 let template_name = match view_mode { 137 266 "calendar" => "bookmark_calendar_grid", 138 267 _ => "bookmark_calendar_timeline", 139 268 }; 140 269 270 + // Get current date for calendar context 271 + let now = chrono::Utc::now(); 272 + let month_names = vec![ 273 + "January", "February", "March", "April", "May", "June", 274 + "July", "August", "September", "October", "November", "December" 275 + ]; 276 + 141 277 let template_context = template_context! { 142 - events => paginated_events.events, 278 + events => processed_events, 143 279 total_count => paginated_events.total_count, 144 280 has_more => paginated_events.has_more, 145 281 current_offset => offset, 146 282 view_mode => view_mode, 147 283 filter_tags => params.tags.unwrap_or_default(), 284 + month => now.month(), 285 + year => now.year(), 286 + month_names => month_names, 148 287 }; 149 288 150 - let html = renderer.render_template( 289 + tracing::info!( 290 + template_name = %template_name, 291 + events_count = events_count, 292 + total_count = paginated_events.total_count, 293 + view_mode = %view_mode, 294 + "About to render bookmark events template" 295 + ); 296 + 297 + let response = renderer.render_template( 151 298 template_name, 152 299 template_context, 153 300 ctx.current_handle.as_ref(), 154 301 "/bookmarks" 155 302 ); 156 - Ok(Html(html).into_response()) 303 + 304 + tracing::info!("Successfully rendered bookmark events template"); 305 + tracing::info!("Response status: {:?}", response.status()); 306 + let result = Ok(response); 307 + tracing::info!("About to return from handle_bookmark_calendar function"); 308 + result 157 309 } 158 310 Err(e) => { 159 311 error!("Failed to get bookmarked events for user {}: {}", current_handle.did, e); 160 - Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to load bookmarks".to_string()).into_response()) 312 + let error_message = LOCALES.lookup(&ctx.language.0, "error-occurred"); 313 + Ok((StatusCode::INTERNAL_SERVER_ERROR, error_message).into_response()) 161 314 } 162 315 } 163 316 } ··· 165 318 /// Handle removing a bookmark 166 319 pub async fn handle_remove_bookmark( 167 320 ctx: UserRequestContext, 168 - Path(bookmark_aturi): Path<String>, 321 + Path(event_aturi): Path<String>, 169 322 ) -> Result<impl IntoResponse, WebError> { 170 323 let current_handle = ctx.auth.require(&ctx.web_context.config.destination_key, "/bookmarks")?; 171 324 172 - // Decode the bookmark AT-URI if URL-encoded 173 - let bookmark_aturi = urlencoding::decode(&bookmark_aturi) 174 - .map_err(|_| anyhow::anyhow!("Invalid bookmark URI"))? 325 + // Decode the event AT-URI if URL-encoded 326 + let event_aturi = urlencoding::decode(&event_aturi) 327 + .map_err(|_| anyhow::anyhow!("Invalid event URI"))? 175 328 .to_string(); 329 + 330 + // Add back the at:// prefix since it was stripped in the JavaScript 331 + let full_event_aturi = format!("at://{}", event_aturi); 176 332 177 333 let bookmark_service = EventBookmarkService::new( 178 334 Arc::new(ctx.web_context.pool.clone()), 179 335 Arc::new(ctx.web_context.atrium_oauth_manager.clone()), 180 336 ); 181 337 338 + // Get session group from the Auth context 339 + let session_group = ctx.auth.1.as_ref() 340 + .map(|oauth_session| oauth_session.session_group.as_str()) 341 + .unwrap_or("unknown"); // Fallback if no session 342 + 182 343 match bookmark_service 183 - .remove_bookmark(&current_handle.did, &bookmark_aturi) 344 + .remove_bookmark(&current_handle.did, &full_event_aturi, session_group) 184 345 .await 185 346 { 186 347 Ok(()) => { 187 - info!("Successfully removed bookmark {} for user {}", bookmark_aturi, current_handle.did); 348 + info!("Successfully removed bookmark {} for user {}", full_event_aturi, current_handle.did); 188 349 189 - // Return empty HTML for HTMX to remove the element 190 - Ok(Html(String::new()).into_response()) 350 + // Return HX-Refresh header to reload the page and show the original bookmark button from view_event template 351 + Ok((StatusCode::OK, [("HX-Refresh", "true")], "").into_response()) 191 352 } 192 353 Err(e) => { 193 - error!("Failed to remove bookmark {}: {}", bookmark_aturi, e); 194 - Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to remove bookmark".to_string()).into_response()) 354 + error!("Failed to remove bookmark {}: {}", full_event_aturi, e); 355 + let error_message = LOCALES.lookup(&ctx.language.0, "error-occurred"); 356 + Ok((StatusCode::INTERNAL_SERVER_ERROR, error_message).into_response()) 195 357 } 196 358 } 197 359 } ··· 286 448 } 287 449 288 450 /// Generate iCal content from bookmarked events 289 - fn generate_ical_from_bookmarks(bookmarks: &[crate::storage::event_bookmarks::EventBookmark]) -> String { 451 + fn generate_ical_from_bookmarks(bookmarked_events: &[crate::storage::event_bookmarks::BookmarkedEvent]) -> String { 290 452 let mut ical = String::from("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//smokesignal//EN\r\n"); 291 453 292 - for bookmark in bookmarks { 454 + for bookmarked_event in bookmarked_events { 455 + // Extract start date from the record JSON if available 456 + let start_date = bookmarked_event.event.record 457 + .get("start_date") 458 + .and_then(|v| v.as_str()) 459 + .unwrap_or("20240101T000000Z"); 460 + 293 461 ical.push_str(&format!( 294 - "BEGIN:VEVENT\r\nUID:{}\r\nSUMMARY:Bookmarked Event\r\nDTSTAMP:{}\r\nEND:VEVENT\r\n", 295 - bookmark.id, 296 - bookmark.created_at.format("%Y%m%dT%H%M%SZ") 462 + "BEGIN:VEVENT\r\nUID:{}\r\nSUMMARY:{}\r\nDTSTAMP:{}\r\nDTSTART:{}\r\nEND:VEVENT\r\n", 463 + bookmarked_event.bookmark.id, 464 + bookmarked_event.event.name, 465 + bookmarked_event.bookmark.created_at.format("%Y%m%dT%H%M%SZ"), 466 + start_date 297 467 )); 298 468 } 299 469 300 470 ical.push_str("END:VCALENDAR\r\n"); 301 471 ical 302 472 } 473 + 474 + #[derive(Deserialize)] 475 + pub struct BookmarkModalParams { 476 + event_aturi: String, 477 + } 478 + 479 + /// Handle showing the bookmark modal 480 + pub async fn handle_bookmark_modal( 481 + ctx: UserRequestContext, 482 + Query(params): Query<BookmarkModalParams>, 483 + ) -> Result<impl IntoResponse, WebError> { 484 + let current_handle = ctx.auth.require(&ctx.web_context.config.destination_key, "/bookmarks/modal")?; 485 + 486 + // Check if the event is already bookmarked 487 + let bookmark_service = EventBookmarkService::new( 488 + Arc::new(ctx.web_context.pool.clone()), 489 + Arc::new(ctx.web_context.atrium_oauth_manager.clone()), 490 + ); 491 + 492 + let existing_bookmark = match bookmark_service 493 + .get_bookmark(&current_handle.did, &params.event_aturi) 494 + .await 495 + { 496 + Ok(bookmark) => bookmark, 497 + Err(e) => { 498 + error!("Failed to check bookmark status: {}", e); 499 + None 500 + } 501 + }; 502 + 503 + // Create the template renderer with HTMX request detection 504 + let language_clone = ctx.language.clone(); 505 + let renderer = create_renderer!(ctx.web_context.clone(), language_clone, true, true); // hx_request = true 506 + 507 + Ok(renderer.render_template( 508 + "bookmark_modal", 509 + template_context! { 510 + event_aturi => params.event_aturi, 511 + existing_bookmark => existing_bookmark, 512 + is_already_bookmarked => existing_bookmark.is_some(), 513 + }, 514 + Some(&current_handle), 515 + "/bookmarks/modal", 516 + )) 517 + }
+1 -1
src/http/handle_create_event.rs
··· 298 298 let event_record = CreateRecordRequest { 299 299 repo: current_handle.did.clone(), 300 300 collection: NSID.to_string(), 301 - validate: false, 301 + validate: Some(false), 302 302 record_key: None, 303 303 record: the_record.clone(), 304 304 swap_commit: None,
+1 -1
src/http/handle_create_rsvp.rs
··· 156 156 let rsvp_record = PutRecordRequest { 157 157 repo: current_handle.did.clone(), 158 158 collection: NSID.to_string(), 159 - validate: false, 159 + validate: Some(false), 160 160 record_key, 161 161 record: the_record.clone(), 162 162 swap_commit: None,
+1 -1
src/http/handle_edit_event.rs
··· 638 638 collection: LexiconCommunityEventNSID.to_string(), 639 639 record_key: event_rkey.clone(), 640 640 record: updated_record.clone(), 641 - validate: false, 641 + validate: Some(false), 642 642 swap_commit: None, 643 643 swap_record: Some(event.cid.clone()), 644 644 };
+1 -1
src/http/handle_migrate_event.rs
··· 266 266 collection: COMMUNITY_NSID.to_string(), 267 267 record_key: event_rkey.clone(), 268 268 record: new_event.clone(), 269 - validate: false, 269 + validate: Some(false), 270 270 swap_commit: None, 271 271 swap_record: None, // We're creating a new record, not replacing 272 272 };
+1 -1
src/http/handle_migrate_rsvp.rs
··· 236 236 let rsvp_record = PutRecordRequest { 237 237 repo: current_handle.did.clone(), 238 238 collection: RSVP_COLLECTION.to_string(), 239 - validate: false, 239 + validate: Some(false), 240 240 record_key, 241 241 record: rsvp_record_content.clone(), 242 242 swap_commit: None,
+10
src/http/handle_profile.rs
··· 177 177 active: tab == ProfileTab::RecentlyUpdated, 178 178 }]; 179 179 180 + // Fetch public bookmark calendars for this profile 181 + let public_calendars = match crate::storage::bookmark_calendars::get_public_by_user(&ctx.web_context.pool, &profile.did).await { 182 + Ok(calendars) => calendars, 183 + Err(e) => { 184 + tracing::warn!("Failed to fetch public calendars for profile {}: {}", profile.did, e); 185 + Vec::new() 186 + } 187 + }; 188 + 180 189 let canonical_url = format!( 181 190 "https://{}/{}", 182 191 ctx.web_context.config.external_base, ··· 194 203 tabs => tab_links, 195 204 events, 196 205 pagination => pagination_view, 206 + public_calendars, 197 207 }, 198 208 ctx.current_handle.as_ref(), 199 209 &canonical_url,
+7 -6
src/http/server.rs
··· 63 63 handle_view_rsvp::handle_view_rsvp, 64 64 handle_bookmark_events::{ 65 65 handle_bookmark_event, handle_bookmark_calendar, handle_remove_bookmark, 66 - handle_calendar_navigation, handle_bookmark_calendar_ical, 66 + handle_calendar_navigation, handle_bookmark_calendar_ical, handle_bookmark_modal, 67 67 }, 68 68 handle_bookmark_calendars::{ 69 69 handle_bookmark_calendars_index, handle_create_bookmark_calendar, ··· 163 163 // Bookmark event routes 164 164 .route("/bookmarks", get(handle_bookmark_calendar)) 165 165 .route("/bookmarks", post(handle_bookmark_event)) 166 - .route("/bookmarks/:bookmark_aturi", delete(handle_remove_bookmark)) 166 + .route("/bookmarks/{bookmark_aturi}", delete(handle_remove_bookmark)) 167 167 .route("/bookmarks/calendar-nav", get(handle_calendar_navigation)) 168 + .route("/bookmarks/modal", get(handle_bookmark_modal)) 168 169 .route("/bookmarks.ics", get(handle_bookmark_calendar_ical)) 169 170 // Bookmark calendar management routes 170 171 .route("/bookmark-calendars", get(handle_bookmark_calendars_index)) 171 172 .route("/bookmark-calendars", post(handle_create_bookmark_calendar)) 172 - .route("/bookmark-calendars/:calendar_id", get(handle_view_bookmark_calendar)) 173 - .route("/bookmark-calendars/:calendar_id", put(handle_update_bookmark_calendar)) 174 - .route("/bookmark-calendars/:calendar_id", delete(handle_delete_bookmark_calendar)) 175 - .route("/bookmark-calendars/:calendar_id.ics", get(handle_export_calendar_ical)) 173 + .route("/bookmark-calendars/{calendar_id}", get(handle_view_bookmark_calendar)) 174 + .route("/bookmark-calendars/{calendar_id}", put(handle_update_bookmark_calendar)) 175 + .route("/bookmark-calendars/{calendar_id}", delete(handle_delete_bookmark_calendar)) 176 + .route("/bookmark-calendars/{calendar_id}/export.ics", get(handle_export_calendar_ical)) 176 177 .route("/favicon.ico", get(|| async { axum::response::Redirect::permanent("/static/favicon.ico") })) 177 178 .route("/{handle_slug}", get(handle_profile_view)) 178 179 .nest_service("/static", serve_dir.clone())
+83 -8
src/http/template_renderer.rs
··· 48 48 canonical_url: &str, 49 49 ) -> Response { 50 50 let template_name = self.select_template(template_base); 51 + tracing::info!( 52 + template_base = %template_base, 53 + template_name = %template_name, 54 + locale = %self.locale, 55 + hx_request = self.hx_request, 56 + hx_boosted = self.hx_boosted, 57 + "Starting template rendering" 58 + ); 59 + 51 60 let enhanced_context = self.enhance_context(context, current_handle, canonical_url); 52 61 53 - RenderHtml( 54 - &template_name, 55 - self.web_context.engine.clone(), 56 - enhanced_context, 57 - ) 58 - .into_response() 62 + tracing::debug!( 63 + template_name = %template_name, 64 + context_type = ?enhanced_context.kind(), 65 + context_len = enhanced_context.len().unwrap_or(0), 66 + "About to call RenderHtml with enhanced context" 67 + ); 68 + 69 + // Try to render the template and capture any errors 70 + let render_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { 71 + RenderHtml( 72 + &template_name, 73 + self.web_context.engine.clone(), 74 + enhanced_context, 75 + ) 76 + .into_response() 77 + })); 78 + 79 + let response = match render_result { 80 + Ok(resp) => { 81 + tracing::info!( 82 + template_name = %template_name, 83 + status = ?resp.status(), 84 + "Template rendering completed" 85 + ); 86 + resp 87 + } 88 + Err(panic_info) => { 89 + tracing::error!( 90 + template_name = %template_name, 91 + error = ?panic_info, 92 + "Template rendering panicked" 93 + ); 94 + // Return a simple error response 95 + (axum::http::StatusCode::INTERNAL_SERVER_ERROR, "Template rendering failed").into_response() 96 + } 97 + }; 98 + 99 + response 59 100 } 60 101 61 102 /// Render an error template with proper error handling ··· 126 167 /// Select the appropriate template based on HTMX state and locale 127 168 fn select_template(&self, base_name: &str) -> String { 128 169 // Use the same fallback logic as the original select_template! macro 129 - select_template_with_fallback( 170 + let template_name = select_template_with_fallback( 130 171 base_name, 131 172 &self.locale, 132 173 self.hx_boosted, 133 174 self.hx_request, 134 - ) 175 + ); 176 + 177 + // Check if the template file exists 178 + let template_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) 179 + .join("templates") 180 + .join(&template_name); 181 + 182 + tracing::debug!( 183 + template_name = %template_name, 184 + template_path = ?template_path, 185 + template_exists = template_path.exists(), 186 + "Selected template for rendering" 187 + ); 188 + 189 + if !template_path.exists() { 190 + tracing::warn!( 191 + template_name = %template_name, 192 + template_path = ?template_path, 193 + "Template file does not exist!" 194 + ); 195 + } 196 + 197 + template_name 135 198 } 136 199 137 200 /// Enhance template context with i18n and system variables ··· 143 206 ) -> minijinja::Value { 144 207 // Normalize locale to lowercase for consistent template naming 145 208 let normalized_locale = self.locale.to_string().to_lowercase(); 209 + 210 + tracing::debug!( 211 + base_context_kind = ?base_context.kind(), 212 + normalized_locale = %normalized_locale, 213 + "Enhancing template context" 214 + ); 215 + 216 + // Log context variables for debugging 217 + if let minijinja::value::ValueKind::Map = base_context.kind() { 218 + tracing::debug!("Template context contains a map with {} items", 219 + base_context.len().unwrap_or(0)); 220 + } 146 221 147 222 // Create enhancement context 148 223 let enhancement_context = template_context! {
+231 -10
src/services/event_bookmarks.rs
··· 3 3 use std::sync::Arc; 4 4 use tracing::{debug, info}; 5 5 use uuid::Uuid; 6 + use std::hash::Hasher; 7 + use metrohash::MetroHash64; 8 + use crockford; 6 9 7 10 use crate::atproto::atrium_auth::AtriumOAuthManager; 11 + use crate::atproto::lexicon::community::lexicon::bookmarks::{Bookmark, BOOKMARK_NSID}; 12 + use crate::atproto::client::{OAuthPdsClient, PutRecordRequest, DeleteRecordRequest}; // Change to PutRecordRequest and add DeleteRecordRequest 13 + use crate::http::middleware_auth::WebSession; 8 14 use crate::storage::{event_bookmarks, StoragePool}; 9 15 use crate::storage::event_bookmarks::{EventBookmark, PaginatedBookmarkedEvents}; 10 16 ··· 28 34 did: &str, 29 35 event_aturi: &str, 30 36 tags: Vec<String>, 37 + session_group: &str, 31 38 ) -> Result<EventBookmark> { 32 - info!("Creating bookmark for event {} by user {}", event_aturi, did); 39 + info!("Creating bookmark for event {} by user {} with tags: {:?}", event_aturi, did, tags); 40 + 41 + // 1. Create bookmark record in ATproto (source of truth) 42 + let bookmark = Bookmark { 43 + subject: event_aturi.to_string(), 44 + created_at: Utc::now(), // Use DateTime<Utc> directly like events 45 + tags: if tags.is_empty() { None } else { Some(tags.clone()) }, 46 + }; 47 + 48 + info!("Bookmark structure before ATProto creation: {:?}", bookmark); 49 + 50 + // Get ATproto client for this user's session 51 + let atproto_client = OAuthPdsClient { 52 + atrium_manager: &self.oauth_manager, 53 + }; 54 + 55 + // Create a minimal WebSession for ATproto calls 56 + let web_session = WebSession { 57 + did: did.to_string(), 58 + session_group: session_group.to_string(), 59 + }; 60 + 61 + // Create the bookmark record in ATproto 62 + // Generate deterministic record key from event URI (like RSVPs do) 63 + let mut h = MetroHash64::default(); 64 + h.write(event_aturi.as_bytes()); 65 + let record_key = crockford::encode(h.finish()); 33 66 34 - // For now, create a fake bookmark AT-URI since we don't have full ATproto integration yet 35 - let bookmark_aturi = format!("at://{}/community.lexicon.bookmarks.bookmark/{}", did, Uuid::new_v4()); 67 + let put_request = PutRecordRequest { 68 + repo: did.to_string(), 69 + collection: BOOKMARK_NSID.to_string(), 70 + record_key, 71 + validate: None, // Use optimistic validation (default) - create record even if lexicon is unknown 72 + record: bookmark, 73 + swap_commit: None, 74 + swap_record: None, 75 + }; 76 + 77 + // Create the ATproto record using PUT (like RSVPs) 78 + let atproto_result = atproto_client.put_record(&web_session, put_request).await; 79 + 80 + let bookmark_aturi = match atproto_result { 81 + Ok(strong_ref) => { 82 + info!("Successfully created ATproto bookmark record: {}", strong_ref.uri); 83 + strong_ref.uri 84 + } 85 + Err(e) => { 86 + // For now, fall back to local-only if ATproto fails 87 + tracing::warn!("ATproto bookmark creation failed: {}, falling back to local-only", e); 88 + format!("at://{}/community.lexicon.bookmarks.bookmark/{}", did, Uuid::new_v4()) 89 + } 90 + }; 36 91 37 - // Store locally for performance 92 + // 2. Store locally for performance 38 93 let local_bookmark = event_bookmarks::insert( 39 94 &self.storage, 40 95 did, ··· 51 106 pub async fn remove_bookmark( 52 107 &self, 53 108 did: &str, 54 - bookmark_aturi: &str, 109 + event_aturi: &str, 110 + session_group: &str, 55 111 ) -> Result<()> { 56 - info!("Removing bookmark {} for user {}", bookmark_aturi, did); 112 + info!("Removing bookmark {} for user {}", event_aturi, did); 57 113 58 - // Remove from local cache 59 - event_bookmarks::delete_by_bookmark_aturi(&self.storage, did, bookmark_aturi).await?; 114 + // 1. Delete from ATproto (source of truth) 115 + // Generate the same deterministic record key that was used for creation 116 + let mut h = MetroHash64::default(); 117 + h.write(event_aturi.as_bytes()); 118 + let record_key = crockford::encode(h.finish()); 60 119 61 - info!("Successfully removed bookmark {}", bookmark_aturi); 120 + let web_session = WebSession { 121 + did: did.to_string(), 122 + session_group: session_group.to_string(), 123 + }; 124 + 125 + let delete_request = DeleteRecordRequest { 126 + repo: did.to_string(), 127 + collection: BOOKMARK_NSID.to_string(), 128 + record_key, 129 + swap_record: None, 130 + swap_commit: None, 131 + }; 132 + 133 + let atproto_client = OAuthPdsClient { 134 + atrium_manager: &self.oauth_manager, 135 + }; 136 + 137 + // Try to delete from ATproto, but don't fail if it doesn't exist 138 + match atproto_client.delete_record(&web_session, delete_request).await { 139 + Ok(()) => { 140 + info!("Successfully deleted bookmark from ATproto for event {}", event_aturi); 141 + } 142 + Err(e) => { 143 + // Log but don't fail - the bookmark might not exist on ATproto or network issues 144 + tracing::warn!("Failed to delete bookmark from ATproto: {}, continuing with local deletion", e); 145 + } 146 + } 147 + 148 + // 2. Remove from local cache 149 + event_bookmarks::delete_by_event_aturi(&self.storage, did, event_aturi).await?; 150 + 151 + info!("Successfully removed bookmark {}", event_aturi); 62 152 Ok(()) 63 153 } 64 154 ··· 85 175 86 176 let has_more = (offset + limit) < total_count as i32; 87 177 178 + tracing::debug!( 179 + bookmarks_count = bookmarks.len(), 180 + total_count = %total_count, 181 + "Retrieved bookmarks from database, starting hydration" 182 + ); 183 + 184 + // Hydrate bookmarks with event data 185 + let bookmark_count = bookmarks.len(); 186 + let mut bookmarked_events = Vec::new(); 187 + for bookmark in bookmarks { 188 + tracing::debug!( 189 + bookmark_id = bookmark.id, 190 + event_aturi = %bookmark.event_aturi, 191 + "Processing bookmark for hydration" 192 + ); 193 + 194 + // Try to get the event data 195 + match crate::storage::event::event_get(&self.storage, &bookmark.event_aturi).await { 196 + Ok(event) => { 197 + tracing::debug!( 198 + bookmark_id = bookmark.id, 199 + event_name = %event.name, 200 + "Successfully hydrated bookmark with event data" 201 + ); 202 + let event_details = crate::storage::event::extract_event_details(&event); 203 + bookmarked_events.push(crate::storage::event_bookmarks::BookmarkedEvent::new( 204 + bookmark, 205 + event, 206 + event_details, 207 + )); 208 + } 209 + Err(e) => { 210 + tracing::warn!( 211 + bookmark_id = bookmark.id, 212 + event_aturi = %bookmark.event_aturi, 213 + error = %e, 214 + "Event not found for bookmark, skipping" 215 + ); 216 + } 217 + } 218 + } 219 + 220 + tracing::info!( 221 + total_bookmarks = bookmark_count, 222 + hydrated_events = bookmarked_events.len(), 223 + "Completed bookmark hydration" 224 + ); 225 + 88 226 Ok(PaginatedBookmarkedEvents { 89 - events: bookmarks, 227 + events: bookmarked_events, 90 228 total_count, 91 229 has_more, 92 230 }) ··· 126 264 .map(|b| b.synced_at) 127 265 .max(), 128 266 }) 267 + } 268 + 269 + /// Get a specific bookmark by event AT-URI 270 + pub async fn get_bookmark( 271 + &self, 272 + did: &str, 273 + event_aturi: &str, 274 + ) -> Result<Option<EventBookmark>> { 275 + self.is_event_bookmarked(did, event_aturi).await 276 + } 277 + 278 + /// Update bookmark tags for an existing bookmark 279 + pub async fn update_bookmark_tags( 280 + &self, 281 + did: &str, 282 + event_aturi: &str, 283 + new_tags: Vec<String>, 284 + session_group: &str, 285 + ) -> Result<EventBookmark> { 286 + info!("Updating bookmark tags for event {} by user {}", event_aturi, did); 287 + 288 + // First get existing bookmark to merge tags 289 + let existing_bookmark = self.get_bookmark(did, event_aturi).await? 290 + .ok_or_else(|| anyhow::anyhow!("Bookmark not found"))?; 291 + 292 + // Merge existing tags with new tags, removing duplicates 293 + let mut merged_tags = existing_bookmark.tags.clone(); 294 + for new_tag in &new_tags { 295 + if !merged_tags.contains(new_tag) { 296 + merged_tags.push(new_tag.clone()); 297 + } 298 + } 299 + 300 + // 1. Update the record in ATproto first (source of truth) 301 + let updated_bookmark_record = Bookmark { 302 + subject: event_aturi.to_string(), 303 + created_at: existing_bookmark.created_at, // Keep the original creation time 304 + tags: if merged_tags.is_empty() { None } else { Some(merged_tags.clone()) }, 305 + }; 306 + 307 + // Generate the same record key that was used when creating the bookmark 308 + let mut hasher = MetroHash64::new(); 309 + hasher.write(event_aturi.as_bytes()); 310 + let hash = hasher.finish(); 311 + let record_key = crockford::encode(hash); 312 + 313 + // Get ATproto client for this user's session 314 + let atproto_client = OAuthPdsClient { 315 + atrium_manager: &self.oauth_manager, 316 + }; 317 + 318 + // Create a minimal WebSession for ATproto calls 319 + let web_session = WebSession { 320 + did: did.to_string(), 321 + session_group: session_group.to_string(), 322 + }; 323 + 324 + // Update the bookmark record in ATproto 325 + let request = PutRecordRequest { 326 + repo: did.to_string(), 327 + collection: BOOKMARK_NSID.to_string(), 328 + record_key: record_key.clone(), 329 + record: updated_bookmark_record, 330 + validate: None, // Use optimistic validation (default) - update record even if lexicon is unknown 331 + swap_commit: None, 332 + swap_record: None, 333 + }; 334 + 335 + let response = atproto_client.put_record(&web_session, request).await?; 336 + let bookmark_aturi = response.uri; 337 + 338 + info!("Updated bookmark record in ATproto with URI: {}", bookmark_aturi); 339 + 340 + // 2. Update the tags in the local database 341 + let updated_bookmark = event_bookmarks::update_tags( 342 + &self.storage, 343 + did, 344 + event_aturi, 345 + &merged_tags, 346 + ).await?; 347 + 348 + info!("Successfully updated bookmark tags for event {}", event_aturi); 349 + Ok(updated_bookmark) 129 350 } 130 351 } 131 352
+63 -5
src/storage/bookmark_calendars.rs
··· 106 106 107 107 // Simplified implementation - we'll implement full functionality later 108 108 pub async fn get_by_user( 109 - _pool: &StoragePool, 110 - _did: &str, 111 - _include_private: bool, 109 + pool: &StoragePool, 110 + did: &str, 111 + include_private: bool, 112 112 ) -> Result<Vec<BookmarkCalendar>, StorageError> { 113 - // For now, return empty vector to allow compilation 114 - Ok(Vec::new()) 113 + let query = if include_private { 114 + "SELECT id, calendar_id, did, name, description, tags, tag_operator, is_public, event_count, created_at, updated_at FROM bookmark_calendars WHERE did = $1 ORDER BY created_at DESC" 115 + } else { 116 + "SELECT id, calendar_id, did, name, description, tags, tag_operator, is_public, event_count, created_at, updated_at FROM bookmark_calendars WHERE did = $1 AND is_public = true ORDER BY created_at DESC" 117 + }; 118 + 119 + let rows = sqlx::query(query) 120 + .bind(did) 121 + .fetch_all(pool) 122 + .await?; 123 + 124 + let mut calendars = Vec::new(); 125 + for row in rows { 126 + calendars.push(BookmarkCalendar { 127 + id: row.get("id"), 128 + calendar_id: row.get("calendar_id"), 129 + did: row.get("did"), 130 + name: row.get("name"), 131 + description: row.get("description"), 132 + tags: row.get::<Vec<String>, _>("tags"), 133 + tag_operator: row.get("tag_operator"), 134 + is_public: row.get("is_public"), 135 + event_count: row.get("event_count"), 136 + created_at: row.get("created_at"), 137 + updated_at: row.get("updated_at"), 138 + }); 139 + } 140 + 141 + Ok(calendars) 142 + } 143 + 144 + pub async fn get_public_by_user( 145 + pool: &StoragePool, 146 + did: &str, 147 + ) -> Result<Vec<BookmarkCalendar>, StorageError> { 148 + let rows = sqlx::query( 149 + "SELECT id, calendar_id, did, name, description, tags, tag_operator, is_public, event_count, created_at, updated_at FROM bookmark_calendars WHERE did = $1 AND is_public = true ORDER BY created_at DESC" 150 + ) 151 + .bind(did) 152 + .fetch_all(pool) 153 + .await?; 154 + 155 + let mut calendars = Vec::new(); 156 + for row in rows { 157 + calendars.push(BookmarkCalendar { 158 + id: row.get("id"), 159 + calendar_id: row.get("calendar_id"), 160 + did: row.get("did"), 161 + name: row.get("name"), 162 + description: row.get("description"), 163 + tags: row.get::<Vec<String>, _>("tags"), 164 + tag_operator: row.get("tag_operator"), 165 + is_public: row.get("is_public"), 166 + event_count: row.get("event_count"), 167 + created_at: row.get("created_at"), 168 + updated_at: row.get("updated_at"), 169 + }); 170 + } 171 + 172 + Ok(calendars) 115 173 } 116 174 117 175 pub async fn get_public_paginated(
+2 -1
src/storage/event.rs
··· 3 3 4 4 use anyhow::Result; 5 5 use chrono::Utc; 6 + use serde::Serialize; 6 7 use serde_json::json; 7 8 use sqlx::{Postgres, QueryBuilder}; 8 9 ··· 550 551 } 551 552 552 553 // Structure to hold extracted event details regardless of source format 553 - #[derive(Debug, Clone)] 554 + #[derive(Debug, Clone, Serialize)] 554 555 pub struct EventDetails { 555 556 pub name: Cow<'static, str>, 556 557 pub description: Cow<'static, str>,
+66 -7
src/storage/event_bookmarks.rs
··· 19 19 pub struct BookmarkedEvent { 20 20 pub bookmark: EventBookmark, 21 21 pub event: super::event::model::Event, 22 + pub event_details: super::event::EventDetails, 23 + #[serde(serialize_with = "serialize_event_rkey")] 24 + pub event_rkey: String, 25 + } 26 + 27 + fn serialize_event_rkey<S>(event_rkey: &String, serializer: S) -> Result<S::Ok, S::Error> 28 + where 29 + S: serde::Serializer, 30 + { 31 + serializer.serialize_str(event_rkey) 32 + } 33 + 34 + impl BookmarkedEvent { 35 + pub fn new(bookmark: EventBookmark, event: super::event::model::Event, event_details: super::event::EventDetails) -> Self { 36 + // Extract rkey from event aturi (e.g., at://did:plc:xyz/collection/rkey -> rkey) 37 + let event_rkey = event.aturi.split('/').last().unwrap_or("").to_string(); 38 + Self { 39 + bookmark, 40 + event, 41 + event_details, 42 + event_rkey, 43 + } 44 + } 22 45 } 23 46 24 47 #[derive(Debug, Clone, Serialize)] 25 48 pub struct PaginatedBookmarkedEvents { 26 - pub events: Vec<EventBookmark>, 49 + pub events: Vec<BookmarkedEvent>, 27 50 pub total_count: i64, 28 51 pub has_more: bool, 29 52 } ··· 75 98 76 99 // Insert all bookmarks from ATproto 77 100 for record in bookmark_records { 101 + // Extract fields from the struct 102 + let subject = &record.value.subject; 103 + let tags = &record.value.tags; 104 + 78 105 sqlx::query( 79 106 r#" 80 107 INSERT INTO event_bookmarks (did, bookmark_aturi, event_aturi, tags, synced_at) ··· 87 114 ) 88 115 .bind(did) 89 116 .bind(&record.uri) 90 - .bind(&record.value.subject) 91 - .bind(&record.value.tags) 117 + .bind(subject) 118 + .bind(tags.as_ref().unwrap_or(&vec![])) 92 119 .execute(&mut *tx) 93 120 .await?; 94 121 } ··· 187 214 Ok(bookmarks) 188 215 } 189 216 190 - pub async fn delete_by_bookmark_aturi( 217 + pub async fn delete_by_event_aturi( 191 218 pool: &StoragePool, 192 219 did: &str, 193 - bookmark_aturi: &str, 220 + event_aturi: &str, 194 221 ) -> Result<(), StorageError> { 195 - sqlx::query("DELETE FROM event_bookmarks WHERE did = $1 AND bookmark_aturi = $2") 222 + sqlx::query("DELETE FROM event_bookmarks WHERE did = $1 AND event_aturi = $2") 196 223 .bind(did) 197 - .bind(bookmark_aturi) 224 + .bind(event_aturi) 198 225 .execute(pool) 199 226 .await?; 200 227 Ok(()) ··· 245 272 Ok(None) 246 273 } 247 274 } 275 + 276 + /// Update tags for an existing bookmark 277 + pub async fn update_tags( 278 + pool: &StoragePool, 279 + did: &str, 280 + event_aturi: &str, 281 + tags: &[String], 282 + ) -> Result<EventBookmark, StorageError> { 283 + let result = sqlx::query( 284 + r#" 285 + UPDATE event_bookmarks 286 + SET tags = $3, synced_at = NOW() 287 + WHERE did = $1 AND event_aturi = $2 288 + RETURNING id, did, bookmark_aturi, event_aturi, tags, synced_at, created_at 289 + "# 290 + ) 291 + .bind(did) 292 + .bind(event_aturi) 293 + .bind(tags) 294 + .fetch_one(pool) 295 + .await?; 296 + 297 + Ok(EventBookmark { 298 + id: result.get("id"), 299 + did: result.get("did"), 300 + bookmark_aturi: result.get("bookmark_aturi"), 301 + event_aturi: result.get("event_aturi"), 302 + tags: result.get::<Vec<String>, _>("tags"), 303 + synced_at: result.get("synced_at"), 304 + created_at: result.get("created_at"), 305 + }) 306 + }
+470 -19
static/bookmark-calendars.js
··· 1 1 // Bookmark Calendar JavaScript 2 2 let calendarTags = []; 3 + let listTags = []; 4 + 5 + // Helper function to get localized bookmark success message 6 + function getBookmarkSuccessMessage(bookmarkTags) { 7 + const baseMessage = document.body.dataset.i18nBookmarkSuccess || 'Event bookmarked successfully!'; 8 + if (bookmarkTags && bookmarkTags.length > 0) { 9 + // For now, just return the base message since we don't have a template for "with tags" 10 + // Could be enhanced later to include tag information 11 + return baseMessage; 12 + } 13 + return baseMessage; 14 + } 3 15 4 16 // Initialize page 5 17 document.addEventListener('DOMContentLoaded', function() { 6 - // Initialize tag input handling 7 - const tagInput = document.getElementById('new-calendar-tag-input'); 8 - if (tagInput) { 9 - tagInput.addEventListener('keydown', handleTagInput); 18 + // Initialize tag input handling for calendar tags 19 + const calendarTagInput = document.getElementById('new-calendar-tag-input'); 20 + if (calendarTagInput) { 21 + calendarTagInput.addEventListener('keydown', handleTagInput); 22 + } 23 + 24 + // Initialize tag input handling for list tags 25 + const listTagInput = document.getElementById('new-list-tag-input'); 26 + if (listTagInput) { 27 + listTagInput.addEventListener('keydown', handleListTagInput); 10 28 } 29 + 30 + // Close modal when clicking background 31 + const modal = document.getElementById('create-list-modal'); 32 + if (modal) { 33 + const background = modal.querySelector('.modal-background'); 34 + if (background) { 35 + background.addEventListener('click', closeListModal); 36 + } 37 + } 38 + 39 + // Handle escape key 40 + document.addEventListener('keydown', function(event) { 41 + if (event.key === 'Escape') { 42 + closeCalendarModal(); 43 + closeListModal(); 44 + } 45 + }); 11 46 }); 12 47 13 48 // Handle tag input for calendar creation ··· 26 61 const tag = input.value.trim(); 27 62 if (tag && !calendarTags.includes(tag)) { 28 63 if (calendarTags.length >= 10) { 29 - alert('Maximum 10 tags allowed per calendar'); 64 + const maxTagsMessage = document.body.dataset.i18nErrorMaxTags || 'Maximum 10 tags allowed per calendar'; 65 + alert(maxTagsMessage); 30 66 return; 31 67 } 32 68 ··· 92 128 htmx.trigger(form, 'submit'); 93 129 } 94 130 131 + // Enhanced list creation functionality 132 + function handleListTagInput(event) { 133 + if (event.key === 'Enter' || event.key === ',') { 134 + event.preventDefault(); 135 + addListTag(); 136 + } 137 + } 138 + 139 + function addListTag() { 140 + const input = document.getElementById('new-list-tag-input'); 141 + const tag = input.value.trim().toLowerCase(); 142 + 143 + if (tag && !listTags.includes(tag)) { 144 + listTags.push(tag); 145 + renderListTags(); 146 + input.value = ''; 147 + } 148 + } 149 + 150 + function removeListTag(tag) { 151 + listTags = listTags.filter(t => t !== tag); 152 + renderListTags(); 153 + } 154 + 155 + function renderListTags() { 156 + const container = document.getElementById('selected-list-tags'); 157 + if (container) { 158 + container.innerHTML = listTags.map(tag => ` 159 + <span class="tag is-primary"> 160 + ${tag} 161 + <button class="delete is-small" onclick="removeListTag('${tag}')"></button> 162 + </span> 163 + `).join(''); 164 + } 165 + } 166 + 167 + function submitListForm() { 168 + const form = document.getElementById('create-list-form'); 169 + const formData = new FormData(form); 170 + 171 + const payload = { 172 + name: formData.get('name'), 173 + description: formData.get('description'), 174 + tags: JSON.stringify(listTags), 175 + tag_operator: formData.get('tag_operator') || 'OR', 176 + is_public: formData.has('is_public') 177 + }; 178 + 179 + fetch('/bookmark-calendars', { 180 + method: 'POST', 181 + headers: { 'Content-Type': 'application/json' }, 182 + body: JSON.stringify(payload) 183 + }) 184 + .then(response => { 185 + if (response.ok) { 186 + closeListModal(); 187 + showNotification('Calendar created successfully', 'success'); 188 + // Reset form state 189 + listTags = []; 190 + renderListTags(); 191 + form.reset(); 192 + // Refresh the page to show the new calendar 193 + window.location.reload(); 194 + } else { 195 + return response.text().then(text => { 196 + throw new Error(text); 197 + }); 198 + } 199 + }) 200 + .catch(error => { 201 + console.error('Error:', error); 202 + showNotification('Error creating calendar', 'error'); 203 + }); 204 + } 205 + 206 + function closeListModal() { 207 + const modal = document.getElementById('create-list-modal'); 208 + if (modal) { 209 + modal.classList.remove('is-active'); 210 + } 211 + } 212 + 213 + function openListModal() { 214 + const modal = document.getElementById('create-list-modal'); 215 + if (modal) { 216 + modal.classList.add('is-active'); 217 + // Clear any previous state 218 + listTags = []; 219 + renderListTags(); 220 + const form = document.getElementById('create-list-form'); 221 + if (form) { 222 + form.reset(); 223 + } 224 + } 225 + } 226 + 95 227 // Smart calendar suggestions based on existing tags 96 228 function suggestCalendarTags(eventTags) { 97 229 const suggestions = document.getElementById('tag-suggestions'); ··· 146 278 }); 147 279 } 148 280 149 - // Calendar navigation 150 - function navigateCalendar(year, month) { 151 - const url = `/bookmarks/calendar-nav?year=${year}&month=${month}`; 152 - 153 - htmx.ajax('GET', url, { 154 - target: '#mini-calendar', 155 - swap: 'outerHTML' 156 - }); 157 - } 158 - 159 281 // Toggle view mode 160 282 function toggleViewMode(mode) { 161 283 const url = `/bookmarks?view=${mode}`; ··· 175 297 // Export calendar 176 298 function exportCalendar(calendarId) { 177 299 const url = calendarId ? 178 - `/bookmark-calendars/${calendarId}.ics` : 300 + `/bookmark-calendars/${calendarId}/export.ics` : 179 301 `/bookmarks.ics`; 180 302 181 303 window.open(url, '_blank'); ··· 199 321 } 200 322 } 201 323 324 + // Generic notification system 325 + function showNotification(message, type = 'info') { 326 + // Create notification element 327 + const notification = document.createElement('div'); 328 + notification.className = `notification is-${type} notification-toast`; 329 + notification.innerHTML = ` 330 + <button class="delete" onclick="this.parentElement.remove()"></button> 331 + ${message} 332 + `; 333 + 334 + // Add to page 335 + const container = document.getElementById('notification-container') || document.body; 336 + container.appendChild(notification); 337 + 338 + // Auto-remove after 5 seconds 339 + setTimeout(() => { 340 + if (notification.parentElement) { 341 + notification.remove(); 342 + } 343 + }, 5000); 344 + } 345 + 346 + // Share bookmark list functionality 347 + function initializeShareButtons() { 348 + const shareButtons = document.querySelectorAll('.share-list-btn'); 349 + shareButtons.forEach(button => { 350 + button.addEventListener('click', function() { 351 + const listUrl = this.getAttribute('data-list-url'); 352 + shareBookmarkList(listUrl); 353 + }); 354 + }); 355 + } 356 + 357 + function shareBookmarkList(listUrl) { 358 + if (navigator.share) { 359 + // Use Web Share API if available 360 + navigator.share({ 361 + title: 'Bookmark List', 362 + text: 'Check out this bookmark list', 363 + url: listUrl 364 + }).catch(err => { 365 + console.log('Error sharing:', err); 366 + fallbackShare(listUrl); 367 + }); 368 + } else { 369 + fallbackShare(listUrl); 370 + } 371 + } 372 + 373 + function fallbackShare(listUrl) { 374 + // Copy to clipboard as fallback 375 + navigator.clipboard.writeText(listUrl).then(() => { 376 + showNotification('Link copied to clipboard!', 'success'); 377 + }).catch(() => { 378 + // If clipboard API fails, show the URL in a prompt 379 + prompt('Copy this link:', listUrl); 380 + }); 381 + } 382 + 383 + // Dropdown toggle functionality 384 + function toggleDropdown(button) { 385 + const dropdown = button.closest('.dropdown'); 386 + if (dropdown) { 387 + dropdown.classList.toggle('is-active'); 388 + 389 + // Close other dropdowns 390 + document.querySelectorAll('.dropdown.is-active').forEach(other => { 391 + if (other !== dropdown) { 392 + other.classList.remove('is-active'); 393 + } 394 + }); 395 + } 396 + } 397 + 398 + // Close dropdowns when clicking outside 399 + document.addEventListener('click', function(event) { 400 + if (!event.target.closest('.dropdown')) { 401 + document.querySelectorAll('.dropdown.is-active').forEach(dropdown => { 402 + dropdown.classList.remove('is-active'); 403 + }); 404 + } 405 + }); 406 + 202 407 // Handle HTMX events 203 408 document.addEventListener('htmx:afterSwap', function(event) { 204 409 // Re-initialize any new tag inputs after HTMX swaps 205 - const tagInput = document.getElementById('new-calendar-tag-input'); 206 - if (tagInput) { 207 - tagInput.addEventListener('keydown', handleTagInput); 410 + const calendarTagInput = document.getElementById('new-calendar-tag-input'); 411 + if (calendarTagInput) { 412 + calendarTagInput.addEventListener('keydown', handleTagInput); 413 + } 414 + 415 + const listTagInput = document.getElementById('new-list-tag-input'); 416 + if (listTagInput) { 417 + listTagInput.addEventListener('keydown', handleListTagInput); 418 + } 419 + 420 + // Re-initialize share buttons after HTMX swaps 421 + initializeShareButtons(); 422 + 423 + // If bookmark modal content was swapped in, initialize it 424 + if (event.target.id === 'bookmarkModal') { 425 + // Check if we have existing bookmark data and initialize 426 + const existingTagsScript = event.target.querySelector('script'); 427 + if (existingTagsScript && existingTagsScript.textContent.includes('initializeBookmarkModal')) { 428 + // The script will run automatically, but we can ensure the modal is visible 429 + const modal = document.getElementById('bookmarkModal'); 430 + if (modal && !modal.classList.contains('is-active')) { 431 + modal.classList.add('is-active'); 432 + } 433 + } 208 434 } 209 435 }); 210 436 ··· 213 439 // Escape to close modals 214 440 if (event.key === 'Escape') { 215 441 closeCalendarModal(); 442 + closeListModal(); 216 443 } 217 444 218 445 // Ctrl+B to bookmark current event (if on event page) ··· 224 451 } 225 452 } 226 453 }); 454 + 455 + // Bookmark modal functionality 456 + let bookmarkTags = []; 457 + let existingBookmarkTags = []; 458 + 459 + // Initialize bookmark modal when loaded 460 + function initializeBookmarkModal(existingTags = []) { 461 + bookmarkTags = [...existingTags]; 462 + existingBookmarkTags = [...existingTags]; 463 + renderBookmarkTags(); 464 + } 465 + 466 + function handleTagKeypress(event) { 467 + if (event.key === 'Enter' || event.key === ',') { 468 + event.preventDefault(); 469 + addBookmarkTag(); 470 + } 471 + } 472 + 473 + function addBookmarkTag() { 474 + const input = document.getElementById('bookmark-tags'); 475 + const tag = input.value.trim().replace(',', ''); 476 + 477 + if (tag && !bookmarkTags.includes(tag)) { 478 + bookmarkTags.push(tag); 479 + input.value = ''; 480 + renderBookmarkTags(); 481 + } 482 + } 483 + 484 + function removeBookmarkTag(tag) { 485 + bookmarkTags = bookmarkTags.filter(t => t !== tag); 486 + renderBookmarkTags(); 487 + } 488 + 489 + function renderBookmarkTags() { 490 + const container = document.getElementById('selected-tags'); 491 + if (container) { 492 + container.innerHTML = bookmarkTags.map(tag => 493 + `<span class="tag is-info"> 494 + ${tag} 495 + <button class="delete is-small" onclick="removeBookmarkTag('${tag}')"></button> 496 + </span>` 497 + ).join(''); 498 + } 499 + } 500 + 501 + function submitBookmark() { 502 + const tagsString = bookmarkTags.join(','); 503 + const eventAturiElement = document.querySelector('[data-event-aturi]'); 504 + 505 + if (!eventAturiElement) { 506 + console.error('Could not find event ATURI element'); 507 + return; 508 + } 509 + 510 + const eventAturi = eventAturiElement.getAttribute('data-event-aturi'); 511 + console.log('Raw eventAturi:', eventAturi); 512 + console.log('Encoded eventAturi:', encodeURIComponent(eventAturi)); 513 + 514 + // Build URL with proper encoding 515 + let url = '/bookmarks?event_aturi=' + encodeURIComponent(eventAturi); 516 + if (tagsString) { 517 + url += '&tags=' + encodeURIComponent(tagsString); 518 + } 519 + 520 + // Use fetch API for better error handling 521 + fetch(url, { 522 + method: 'POST', 523 + headers: { 524 + 'Content-Type': 'application/x-www-form-urlencoded', 525 + } 526 + }) 527 + .then(response => { 528 + if (!response.ok) { 529 + throw new Error(`HTTP ${response.status}: ${response.statusText}`); 530 + } 531 + return response.text(); 532 + }) 533 + .then(data => { 534 + // Close the modal 535 + closeBookmarkModal(); 536 + 537 + // Ensure the page is not blocked by removing any stale modal overlays 538 + const allModals = document.querySelectorAll('.modal.is-active'); 539 + allModals.forEach(modal => modal.classList.remove('is-active')); 540 + 541 + // Update the bookmark button on the parent page 542 + const bookmarkFrame = window.parent ? window.parent.document.getElementById('bookmarkFrame') : document.getElementById('bookmarkFrame'); 543 + if (bookmarkFrame) { 544 + // Change the button to show "Remove Bookmark" 545 + bookmarkFrame.innerHTML = ` 546 + <article class="message is-info"> 547 + <div class="message-body"> 548 + <div class="columns is-vcentered"> 549 + <div class="column"> 550 + <span class="icon-text"> 551 + <span class="icon"> 552 + <i class="fas fa-bookmark"></i> 553 + </span> 554 + <span>${getBookmarkSuccessMessage(bookmarkTags)}</span> 555 + </span> 556 + </div> 557 + <div class="column is-narrow"> 558 + <button class="button is-outlined is-danger is-small" 559 + hx-delete="/bookmarks/${encodeURIComponent(eventAturi.replace('at://', ''))}" 560 + hx-target="#bookmarkFrame" 561 + hx-swap="outerHTML" 562 + hx-confirm="${document.body.dataset.i18nConfirmRemoveBookmark || 'Are you sure you want to remove this bookmark?'}"> 563 + <span class="icon"> 564 + <i class="fas fa-times"></i> 565 + </span> 566 + <span>${document.body.dataset.i18nRemoveBookmark || 'Remove Bookmark'}</span> 567 + </button> 568 + </div> 569 + </div> 570 + </div> 571 + </article> `; 572 + 573 + // Re-initialize HTMX for the new elements 574 + if (typeof htmx !== 'undefined') { 575 + htmx.process(bookmarkFrame); 576 + } 577 + } 578 + }) 579 + .catch(error => { 580 + console.error('Bookmark error:', error); 581 + alert('Error bookmarking event: ' + error.message); 582 + }); 583 + } 584 + 585 + function removeBookmark() { 586 + const eventAturiElement = document.querySelector('[data-event-aturi]'); 587 + 588 + if (!eventAturiElement) { 589 + console.error('Could not find event ATURI element'); 590 + return; 591 + } 592 + 593 + const eventAturi = eventAturiElement.getAttribute('data-event-aturi'); 594 + const confirmMessage = document.body.dataset.i18nConfirmRemoveBookmark || 'Are you sure you want to remove this bookmark?'; 595 + 596 + if (confirm(confirmMessage)) { 597 + // Strip at:// prefix and encode for URL 598 + const encodedAturi = encodeURIComponent(eventAturi.replace('at://', '')); 599 + const url = '/bookmarks/' + encodedAturi; 600 + 601 + fetch(url, { method: 'DELETE' }) 602 + .then(response => { 603 + if (!response.ok) { 604 + throw new Error(`HTTP ${response.status}: ${response.statusText}`); 605 + } 606 + // Check if response has HX-Refresh header or handle accordingly 607 + const hxRefresh = response.headers.get('HX-Refresh'); 608 + if (hxRefresh === 'true') { 609 + // Refresh the page 610 + window.location.reload(); 611 + } 612 + return response.text(); 613 + }) 614 + .then(data => { 615 + // Close the modal 616 + closeBookmarkModal(); 617 + }) 618 + .catch(error => { 619 + console.error('Remove bookmark error:', error); 620 + alert('Error removing bookmark: ' + error.message); 621 + }); 622 + } 623 + } 624 + 625 + function closeBookmarkModal() { 626 + // Handle both modal IDs to ensure compatibility 627 + const modalInline = document.getElementById('bookmark-modal'); 628 + const modalContainer = document.getElementById('bookmarkModal'); 629 + 630 + if (modalInline) { 631 + modalInline.remove(); 632 + } 633 + 634 + if (modalContainer) { 635 + modalContainer.classList.remove('is-active'); 636 + // Clear the modal content to prevent stale content 637 + const modalContent = modalContainer.querySelector('.modal-content'); 638 + if (modalContent) { 639 + modalContent.innerHTML = ''; 640 + } 641 + } 642 + } 643 + 644 + function removeBookmarkFromTimeline(button) { 645 + const eventAturi = button.getAttribute('data-event-aturi'); 646 + const confirmMessage = document.body.dataset.i18nConfirmRemoveBookmark || 'Are you sure you want to remove this bookmark?'; 647 + 648 + if (confirm(confirmMessage)) { 649 + // Strip at:// prefix and encode for URL - same as working bookmark removal 650 + const encodedAturi = encodeURIComponent(eventAturi.replace('at://', '')); 651 + const url = '/bookmarks/' + encodedAturi; 652 + 653 + fetch(url, { method: 'DELETE' }) 654 + .then(response => { 655 + if (!response.ok) { 656 + throw new Error(`HTTP ${response.status}: ${response.statusText}`); 657 + } 658 + 659 + // Remove the timeline item from the DOM 660 + const timelineItem = button.closest('.timeline-item'); 661 + if (timelineItem) { 662 + timelineItem.remove(); 663 + 664 + // Check if there are no more events, reload the page to show proper empty state 665 + const timelineContainer = document.querySelector('.timeline'); 666 + if (timelineContainer && timelineContainer.children.length === 0) { 667 + // Reload the page to show the properly translated empty state from the template 668 + window.location.reload(); 669 + } 670 + } 671 + }) 672 + .catch(error => { 673 + console.error('Error removing bookmark:', error); 674 + alert('Failed to remove bookmark. Please try again.'); 675 + }); 676 + } 677 + }
+238
static/bookmark-list-modal.css
··· 1 + /* Bookmark List Modal Styles */ 2 + .tags-input-container { 3 + border: 1px solid #dbdbdb; 4 + border-radius: 4px; 5 + padding: 0.375rem; 6 + min-height: 2.5rem; 7 + display: flex; 8 + flex-wrap: wrap; 9 + align-items: center; 10 + gap: 0.25rem; 11 + background-color: white; 12 + } 13 + 14 + .tags-input-container:focus-within { 15 + border-color: #3273dc; 16 + box-shadow: 0 0 0 0.125em rgba(50, 115, 220, 0.25); 17 + } 18 + 19 + .tags-display { 20 + display: flex; 21 + flex-wrap: wrap; 22 + gap: 0.25rem; 23 + margin-right: 0.5rem; 24 + } 25 + 26 + .tags-display .tag { 27 + margin: 0; 28 + } 29 + 30 + .tags-input-container input { 31 + border: none; 32 + outline: none; 33 + flex: 1; 34 + min-width: 120px; 35 + padding: 0.25rem; 36 + font-size: 1rem; 37 + } 38 + 39 + .tags-input-container input:focus { 40 + box-shadow: none; 41 + } 42 + 43 + /* Modal enhancements */ 44 + .modal-card { 45 + max-width: 600px; 46 + width: 90vw; 47 + } 48 + 49 + .modal-card-body { 50 + max-height: 70vh; 51 + overflow-y: auto; 52 + } 53 + 54 + /* Notification toast styles */ 55 + .notification-toast { 56 + position: fixed; 57 + top: 1rem; 58 + right: 1rem; 59 + z-index: 9999; 60 + max-width: 400px; 61 + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); 62 + animation: slideInRight 0.3s ease-out; 63 + } 64 + 65 + @keyframes slideInRight { 66 + from { 67 + transform: translateX(100%); 68 + opacity: 0; 69 + } 70 + to { 71 + transform: translateX(0); 72 + opacity: 1; 73 + } 74 + } 75 + 76 + /* Tag suggestions styling */ 77 + #tag-suggestions { 78 + margin-top: 0.5rem; 79 + display: flex; 80 + flex-wrap: wrap; 81 + gap: 0.25rem; 82 + } 83 + 84 + #tag-suggestions .button { 85 + font-size: 0.75rem; 86 + height: auto; 87 + padding: 0.25rem 0.5rem; 88 + } 89 + 90 + /* Privacy notice styling */ 91 + .notification.is-info.is-light { 92 + margin-top: 0.5rem; 93 + } 94 + 95 + .notification.is-info.is-light p { 96 + margin: 0; 97 + } 98 + 99 + /* Form field spacing */ 100 + .field:not(:last-child) { 101 + margin-bottom: 1.5rem; 102 + } 103 + 104 + /* Help text styling */ 105 + .help { 106 + font-size: 0.875rem; 107 + color: #6c757d; 108 + margin-top: 0.25rem; 109 + } 110 + 111 + /* Enhanced checkbox styling */ 112 + .checkbox { 113 + display: flex; 114 + align-items: center; 115 + gap: 0.5rem; 116 + } 117 + 118 + .checkbox input[type="checkbox"] { 119 + margin: 0; 120 + } 121 + 122 + /* Profile Bookmark Lists Grid */ 123 + .profile-section { 124 + margin-bottom: 2rem; 125 + } 126 + 127 + .section-header { 128 + display: flex; 129 + justify-content: space-between; 130 + align-items: center; 131 + margin-bottom: 1rem; 132 + padding-bottom: 0.5rem; 133 + border-bottom: 1px solid #e8e8e8; 134 + } 135 + 136 + .section-header .title { 137 + margin-bottom: 0; 138 + display: flex; 139 + align-items: center; 140 + gap: 0.5rem; 141 + } 142 + 143 + .bookmark-lists-grid { 144 + display: grid; 145 + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); 146 + gap: 1rem; 147 + margin-top: 1rem; 148 + } 149 + 150 + .list-card { 151 + border: 1px solid #e8e8e8; 152 + border-radius: 8px; 153 + padding: 1rem; 154 + background-color: white; 155 + transition: box-shadow 0.2s ease; 156 + } 157 + 158 + .list-card:hover { 159 + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); 160 + } 161 + 162 + .list-card-header { 163 + display: flex; 164 + justify-content: space-between; 165 + align-items: flex-start; 166 + margin-bottom: 0.75rem; 167 + } 168 + 169 + .list-name { 170 + margin: 0; 171 + font-size: 1.1rem; 172 + font-weight: 600; 173 + } 174 + 175 + .list-name a { 176 + color: #3273dc; 177 + text-decoration: none; 178 + } 179 + 180 + .list-name a:hover { 181 + text-decoration: underline; 182 + } 183 + 184 + .list-stats { 185 + flex-shrink: 0; 186 + } 187 + 188 + .list-description { 189 + color: #6c757d; 190 + font-size: 0.9rem; 191 + margin-bottom: 1rem; 192 + line-height: 1.4; 193 + } 194 + 195 + .list-card-footer { 196 + display: flex; 197 + justify-content: space-between; 198 + align-items: center; 199 + margin-top: 1rem; 200 + padding-top: 0.75rem; 201 + border-top: 1px solid #f5f5f5; 202 + } 203 + 204 + .list-actions { 205 + display: flex; 206 + gap: 0.5rem; 207 + } 208 + 209 + .empty-state { 210 + text-align: center; 211 + padding: 2rem; 212 + color: #6c757d; 213 + font-style: italic; 214 + } 215 + 216 + /* Responsive adjustments */ 217 + @media (max-width: 768px) { 218 + .bookmark-lists-grid { 219 + grid-template-columns: 1fr; 220 + } 221 + 222 + .section-header { 223 + flex-direction: column; 224 + align-items: flex-start; 225 + gap: 0.5rem; 226 + } 227 + 228 + .list-card-header { 229 + flex-direction: column; 230 + gap: 0.5rem; 231 + } 232 + 233 + .list-card-footer { 234 + flex-direction: column; 235 + align-items: flex-start; 236 + gap: 0.5rem; 237 + } 238 + }
+9 -1
templates/base.en-us.html
··· 11 11 <link rel="stylesheet" href="/static/fontawesome.min.css"> 12 12 <link rel="stylesheet" href="/static/bulma.min.css"> 13 13 <link rel="stylesheet" href="/static/venue-search.css"> 14 + <link rel="stylesheet" href="/static/bookmark-list-modal.css"> 14 15 15 16 <!-- MapLibreGL CSS --> 16 17 <link href="https://unpkg.com/maplibre-gl@3.6.2/dist/maplibre-gl.css" rel="stylesheet"> ··· 22 23 <script src="/static/venue-search.js"></script> 23 24 <script src="/static/location-map-viewer.js"></script> 24 25 <script src="/static/form-enhancement.js"></script> 26 + <script src="/static/bookmark-calendars.js"></script> 25 27 26 28 <!-- MapLibreGL JS --> 27 29 <script src="https://unpkg.com/maplibre-gl@3.6.2/dist/maplibre-gl.js"></script> ··· 87 89 data-i18n-locate-me="{{ t('button-locate-me') }}" 88 90 data-i18n-locating-user="{{ t('locating-user') }}" 89 91 data-i18n-location-error="{{ t('location-error') }}" 90 - data-i18n-location-permission-denied="{{ t('location-permission-denied') }}"> 92 + data-i18n-location-permission-denied="{{ t('location-permission-denied') }}" 93 + data-i18n-bookmark-success="{{ t('bookmark-success') }}" 94 + data-i18n-bookmark-event="{{ t('bookmark-event') }}" 95 + data-i18n-bookmark-removed-success="Bookmark removed successfully!" 96 + data-i18n-confirm-remove-bookmark="{{ t('confirm-remove-bookmark') }}" 97 + data-i18n-remove-bookmark="{{ t('remove-bookmark') }}" 98 + data-i18n-error-max-tags="{{ t('error-max-tags') }}"> 91 99 {% include 'nav.' + current_locale + '.html' %} 92 100 {% block content %}{% endblock %} 93 101 {% include 'footer.' + current_locale + '.html' %}
+54 -1
templates/base.fr-ca.html
··· 11 11 <link rel="stylesheet" href="/static/fontawesome.min.css"> 12 12 <link rel="stylesheet" href="/static/bulma.min.css"> 13 13 <link rel="stylesheet" href="/static/venue-search.css"> 14 + <link rel="stylesheet" href="/static/bookmark-list-modal.css"> 15 + 16 + <!-- Bulma Calendar CSS --> 17 + <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma-calendar@7.1.1/dist/css/bulma-calendar.min.css"> 14 18 15 19 <!-- MapLibreGL CSS --> 16 20 <link href="https://unpkg.com/maplibre-gl@3.6.2/dist/maplibre-gl.css" rel="stylesheet"> ··· 22 26 <script src="/static/venue-search.js"></script> 23 27 <script src="/static/location-map-viewer.js"></script> 24 28 <script src="/static/form-enhancement.js"></script> 29 + <script src="/static/bookmark-calendars.js"></script> 30 + 31 + <!-- Bulma Calendar JS --> 32 + <script src="https://cdn.jsdelivr.net/npm/bulma-calendar@7.1.1/dist/js/bulma-calendar.min.js"></script> 25 33 26 34 <!-- MapLibreGL JS --> 27 35 <script src="https://unpkg.com/maplibre-gl@3.6.2/dist/maplibre-gl.js"></script> ··· 70 78 height: 100%; 71 79 width: 100%; 72 80 z-index: 1; 81 + } 82 + 83 + /* Bulma Calendar dark theme customization */ 84 + .calendar-mini .datetimepicker { 85 + background-color: rgba(255, 255, 255, 0.05); 86 + backdrop-filter: blur(10px); 87 + border-radius: 8px; 88 + border: 1px solid rgba(255, 255, 255, 0.1); 89 + } 90 + 91 + .calendar-mini .datetimepicker .calendar { 92 + background-color: transparent; 93 + } 94 + 95 + .calendar-mini .datetimepicker .calendar .calendar-header { 96 + background-color: rgba(110, 68, 255, 0.1); 97 + color: var(--light); 98 + } 99 + 100 + .calendar-mini .datetimepicker .calendar .calendar-body { 101 + background-color: transparent; 102 + } 103 + 104 + .calendar-mini .datetimepicker .calendar .calendar-date { 105 + color: var(--light); 106 + } 107 + 108 + .calendar-mini .datetimepicker .calendar .calendar-date:hover { 109 + background-color: rgba(110, 68, 255, 0.2); 110 + } 111 + 112 + .calendar-mini .datetimepicker .calendar .calendar-date.is-active { 113 + background-color: var(--primary); 114 + color: white; 115 + } 116 + 117 + .calendar-mini .datetimepicker .calendar .calendar-date.is-today { 118 + color: var(--secondary); 119 + font-weight: bold; 73 120 } 74 121 </style> 75 122 ··· 204 251 data-i18n-locate-me="{{ t('button-locate-me') }}" 205 252 data-i18n-locating-user="{{ t('locating-user') }}" 206 253 data-i18n-location-error="{{ t('location-error') }}" 207 - data-i18n-location-permission-denied="{{ t('location-permission-denied') }}"> 254 + data-i18n-location-permission-denied="{{ t('location-permission-denied') }}" 255 + data-i18n-bookmark-success="{{ t('bookmark-success') }}" 256 + data-i18n-bookmark-event="{{ t('bookmark-event') }}" 257 + data-i18n-bookmark-removed-success="Signet retiré avec succès!" 258 + data-i18n-confirm-remove-bookmark="{{ t('confirm-remove-bookmark') }}" 259 + data-i18n-remove-bookmark="{{ t('remove-bookmark') }}" 260 + data-i18n-error-max-tags="{{ t('error-max-tags') }}"> 208 261 {% include 'nav.' + current_locale + '.html' %} 209 262 {% block content %}{% endblock %} 210 263 {% include 'footer.' + current_locale + '.html' %}
+4
templates/bookmark_calendar_timeline.en-us.bare.html
··· 1 + {% extends "bare.en-us.html" %} 2 + {% block content %} 3 + {% include 'bookmark_calendar_timeline.' + current_locale + '.common.html' %} 4 + {% endblock %}
+53
templates/bookmark_calendar_timeline.en-us.common.html
··· 1 + <section class="section"> 2 + <div class="container"> 3 + <div class="box content"> 4 + <!-- Calendar Header --> 5 + <div class="level"> 6 + <div class="level-left"> 7 + <div class="level-item"> 8 + <div> 9 + <h1 class="title"> 10 + <span class="icon"> 11 + <i class="fas fa-bookmark"></i> 12 + </span> 13 + {{ calendar.name | default(t("bookmark-calendar")) }} 14 + </h1> 15 + {% if calendar.description %} 16 + <p class="subtitle">{{ calendar.description }}</p> 17 + {% endif %} 18 + {% if calendar.tags %} 19 + <div class="tags"> 20 + {% for tag in calendar.tags %} 21 + <span class="tag is-primary is-light">{{ tag }}</span> 22 + {% endfor %} 23 + </div> 24 + {% endif %} 25 + </div> 26 + </div> 27 + </div> 28 + <div class="level-right"> 29 + <div class="level-item"> 30 + <div class="buttons"> 31 + {% if calendar.id %} 32 + <a href="/bookmark-calendars/{{ calendar.id }}.ics" class="button is-info"> 33 + <span class="icon"> 34 + <i class="fas fa-download"></i> 35 + </span> 36 + <span>{{ t("export-calendar") }}</span> 37 + </a> 38 + {% endif %} 39 + <a href="/bookmark-calendars" class="button is-light"> 40 + <span class="icon"> 41 + <i class="fas fa-arrow-left"></i> 42 + </span> 43 + <span>{{ t("back-to-calendars") }}</span> 44 + </a> 45 + </div> 46 + </div> 47 + </div> 48 + </div> 49 + 50 + {% include 'bookmark_calendar_timeline.' + current_locale + '.partial.html' %} 51 + </div> 52 + </div> 53 + </section>
+3 -88
templates/bookmark_calendar_timeline.en-us.html
··· 1 - {% extends "base.html" %} 2 - 3 - {% block title %}{{ t("bookmark-calendar") }}{% endblock %} 4 - 1 + {% extends "base." + current_locale + ".html" %} 2 + {% block title %}{{ t("bookmark-calendar") }} - {{ t("page-title-home") }}{% endblock %} 5 3 {% block content %} 6 - <div class="bookmark-calendar-container"> 7 - <div class="columns"> 8 - <div class="column is-3"> 9 - <!-- Mini Calendar Widget --> 10 - <div class="box"> 11 - <h4 class="title is-5">{{ t("calendar-navigation") }}</h4> 12 - <div id="mini-calendar" hx-get="/bookmarks/calendar-nav" hx-trigger="load"> 13 - <!-- Calendar will be loaded here --> 14 - </div> 15 - </div> 16 - 17 - <!-- Filter Controls --> 18 - <div class="box"> 19 - <h4 class="title is-5">{{ t("filter-by-tags") }}</h4> 20 - <div class="field"> 21 - <div class="control"> 22 - <input class="input" type="text" placeholder="{{ t('bookmark-tags') }}" 23 - id="filter-tags" value="{{ filter_tags }}"> 24 - </div> 25 - </div> 26 - <div class="field"> 27 - <div class="control"> 28 - <button class="button is-primary" 29 - hx-get="/bookmarks" 30 - hx-include="#filter-tags" 31 - hx-target="#timeline-content"> 32 - {{ t("filter-events") }} 33 - </button> 34 - </div> 35 - </div> 36 - </div> 37 - 38 - <!-- My Calendars --> 39 - <div class="box"> 40 - <h4 class="title is-5">{{ t("my-calendars") }}</h4> 41 - <div class="buttons"> 42 - <button class="button is-success is-small" 43 - hx-get="/bookmark-calendars/modal" 44 - hx-target="#modal-container"> 45 - {{ t("create-new-calendar") }} 46 - </button> 47 - </div> 48 - <div id="user-calendars" hx-get="/bookmark-calendars" hx-trigger="load"> 49 - <!-- User calendars will be loaded here --> 50 - </div> 51 - </div> 52 - </div> 53 - 54 - <div class="column is-9"> 55 - <!-- View Mode Toggle --> 56 - <div class="level"> 57 - <div class="level-left"> 58 - <div class="level-item"> 59 - <h2 class="title is-3">{{ t("bookmarked-events") }}</h2> 60 - </div> 61 - </div> 62 - <div class="level-right"> 63 - <div class="level-item"> 64 - <div class="buttons"> 65 - <button class="button {% if view_mode == 'timeline' %}is-primary{% endif %}" 66 - hx-get="/bookmarks?view=timeline" 67 - hx-target="#timeline-content"> 68 - {{ t("timeline-view") }} 69 - </button> 70 - <button class="button {% if view_mode == 'calendar' %}is-primary{% endif %}" 71 - hx-get="/bookmarks?view=calendar" 72 - hx-target="#timeline-content"> 73 - {{ t("calendar-view") }} 74 - </button> 75 - </div> 76 - </div> 77 - </div> 78 - </div> 79 - 80 - <!-- Timeline Content --> 81 - <div id="timeline-content"> 82 - {% include "bookmark_timeline.partial.html" %} 83 - </div> 84 - </div> 85 - </div> 86 - </div> 87 - 88 - <!-- Modal Container --> 89 - <div id="modal-container"></div> 4 + {% include 'bookmark_calendar_timeline.' + current_locale + '.common.html' %} 90 5 {% endblock %} 91 6 92 7 {% block scripts %}
+92
templates/bookmark_calendar_timeline.en-us.partial.html
··· 1 + <div class="bookmark-calendar-container"> 2 + <div class="columns"> 3 + <div class="column"> 4 + <!-- Timeline View --> 5 + <div class="timeline-container" id="timeline-container"> 6 + {% if events %} 7 + <div class="timeline"> 8 + {% for bookmarked_event in events %} 9 + <div class="timeline-item"> 10 + <div class="timeline-marker is-icon"> 11 + <i class="fas fa-bookmark" style="color: #ff6b6b;"></i> 12 + </div> 13 + <div class="timeline-content"> 14 + <div class="event-card box"> 15 + {% if bookmarked_event.bookmark.tags %} 16 + <div class="tags mb-3"> 17 + {% for tag in bookmarked_event.bookmark.tags %} 18 + <span class="tag is-primary is-small">{{ tag }}</span> 19 + {% endfor %} 20 + </div> 21 + {% endif %} 22 + 23 + <h5 class="title is-5">{{ bookmarked_event.event_details.name }}</h5> 24 + {% if bookmarked_event.event_details.description %} 25 + <p class="content">{{ bookmarked_event.event_details.description }}</p> 26 + {% endif %} 27 + 28 + <div class="columns is-mobile"> 29 + <div class="column"> 30 + {% if bookmarked_event.event_details.starts_at %} 31 + <p class="has-text-grey"> 32 + <span class="icon"> 33 + <i class="fas fa-calendar"></i> 34 + </span> 35 + {{ bookmarked_event.event_details.starts_at | date(format="%B %d, %Y") }} 36 + </p> 37 + {% endif %} 38 + {% if bookmarked_event.event_details.locations and bookmarked_event.event_details.locations|length > 0 %} 39 + <p class="has-text-grey"> 40 + <span class="icon"> 41 + <i class="fas fa-map-marker-alt"></i> 42 + </span> 43 + <!-- Simple location display - you can enhance this --> 44 + {{ t("event-location") }} 45 + </p> 46 + {% endif %} 47 + </div> 48 + <div class="column is-narrow"> 49 + <div class="buttons"> 50 + <a href="/events/{{ bookmarked_event.event_rkey }}" class="button is-small is-primary"> 51 + {{ t("view-event") }} 52 + </a> 53 + <button class="button is-small is-danger is-outlined" 54 + hx-delete="/bookmarks/{{ bookmarked_event.bookmark.bookmark_aturi | replace(from='at://', to='') | urlencode }}" 55 + hx-confirm="{{ t('confirm-remove-bookmark') }}" 56 + hx-target="closest .timeline-item" 57 + hx-swap="outerHTML"> 58 + <span class="icon"> 59 + <i class="fas fa-trash"></i> 60 + </span> 61 + <span>{{ t("remove-bookmark") }}</span> 62 + </button> 63 + </div> 64 + </div> 65 + </div> 66 + 67 + <footer class="is-size-7 has-text-grey mt-3"> 68 + {{ t("bookmarked-on", date=bookmarked_event.bookmark.created_at | date(format="%B %d, %Y")) }} 69 + </footer> 70 + </div> 71 + </div> 72 + </div> 73 + {% endfor %} 74 + </div> 75 + {% else %} 76 + <div class="has-text-centered py-6"> 77 + <span class="icon is-large has-text-grey-light"> 78 + <i class="fas fa-calendar-times fa-3x"></i> 79 + </span> 80 + <p class="title is-4 has-text-grey">{{ t("no-bookmarked-events") }}</p> 81 + <p class="subtitle has-text-grey">{{ t("start-bookmarking-events") }}</p> 82 + </div> 83 + {% endif %} 84 + </div> 85 + </div> 86 + <div class="column"> 87 + <div class="is-pulled-right"> 88 + {% include 'mini_calendar.html' %} 89 + </div> 90 + </div> 91 + </div> 92 + </div>
+4
templates/bookmark_calendar_timeline.fr-ca.bare.html
··· 1 + {% extends "bare.fr-ca.html" %} 2 + {% block content %} 3 + {% include 'bookmark_calendar_timeline.' + current_locale + '.common.html' %} 4 + {% endblock %}
+35
templates/bookmark_calendar_timeline.fr-ca.common.html
··· 1 + <section class="section"> 2 + <div class="container"> 3 + <div class="box content"> 4 + <!-- Calendar Header --> 5 + <div class="level"> 6 + <div class="level-left"> 7 + <div class="level-item"> 8 + <div> 9 + <h1 class="title"> 10 + <span class="icon"> 11 + <i class="fas fa-bookmark"></i> 12 + </span> 13 + {{ t("bookmark-calendar") }} 14 + </h1> 15 + </div> 16 + </div> 17 + </div> 18 + <div class="level-right"> 19 + <div class="level-item"> 20 + <div class="buttons"> 21 + <a href="/bookmark-calendars" class="button is-light"> 22 + <span class="icon"> 23 + <i class="fas fa-arrow-left"></i> 24 + </span> 25 + <span>{{ t("back-to-calendars") }}</span> 26 + </a> 27 + </div> 28 + </div> 29 + </div> 30 + </div> 31 + 32 + {% include 'bookmark_calendar_timeline.' + current_locale + '.partial.html' %} 33 + </div> 34 + </div> 35 + </section>
+3 -88
templates/bookmark_calendar_timeline.fr-ca.html
··· 1 - {% extends "base.html" %} 2 - 3 - {% block title %}{{ t("bookmark-calendar") }}{% endblock %} 4 - 1 + {% extends "base.fr-ca.html" %} 2 + {% block title %}{{ t("bookmark-calendar") }} - {{ t("page-title-home") }}{% endblock %} 5 3 {% block content %} 6 - <div class="bookmark-calendar-container"> 7 - <div class="columns"> 8 - <div class="column is-3"> 9 - <!-- Mini Calendar Widget --> 10 - <div class="box"> 11 - <h4 class="title is-5">{{ t("calendar-navigation") }}</h4> 12 - <div id="mini-calendar" hx-get="/bookmarks/calendar-nav" hx-trigger="load"> 13 - <!-- Le calendrier sera chargé ici --> 14 - </div> 15 - </div> 16 - 17 - <!-- Contrôles de filtrage --> 18 - <div class="box"> 19 - <h4 class="title is-5">{{ t("filter-by-tags") }}</h4> 20 - <div class="field"> 21 - <div class="control"> 22 - <input class="input" type="text" placeholder="{{ t('bookmark-tags') }}" 23 - id="filter-tags" value="{{ filter_tags }}"> 24 - </div> 25 - </div> 26 - <div class="field"> 27 - <div class="control"> 28 - <button class="button is-primary" 29 - hx-get="/bookmarks" 30 - hx-include="#filter-tags" 31 - hx-target="#timeline-content"> 32 - {{ t("filter-events") }} 33 - </button> 34 - </div> 35 - </div> 36 - </div> 37 - 38 - <!-- Mes calendriers --> 39 - <div class="box"> 40 - <h4 class="title is-5">{{ t("my-calendars") }}</h4> 41 - <div class="buttons"> 42 - <button class="button is-success is-small" 43 - hx-get="/bookmark-calendars/modal" 44 - hx-target="#modal-container"> 45 - {{ t("create-new-calendar") }} 46 - </button> 47 - </div> 48 - <div id="user-calendars" hx-get="/bookmark-calendars" hx-trigger="load"> 49 - <!-- Les calendriers utilisateur seront chargés ici --> 50 - </div> 51 - </div> 52 - </div> 53 - 54 - <div class="column is-9"> 55 - <!-- Basculement de mode d'affichage --> 56 - <div class="level"> 57 - <div class="level-left"> 58 - <div class="level-item"> 59 - <h2 class="title is-3">{{ t("bookmarked-events") }}</h2> 60 - </div> 61 - </div> 62 - <div class="level-right"> 63 - <div class="level-item"> 64 - <div class="buttons"> 65 - <button class="button {% if view_mode == 'timeline' %}is-primary{% endif %}" 66 - hx-get="/bookmarks?view=timeline" 67 - hx-target="#timeline-content"> 68 - {{ t("timeline-view") }} 69 - </button> 70 - <button class="button {% if view_mode == 'calendar' %}is-primary{% endif %}" 71 - hx-get="/bookmarks?view=calendar" 72 - hx-target="#timeline-content"> 73 - {{ t("calendar-view") }} 74 - </button> 75 - </div> 76 - </div> 77 - </div> 78 - </div> 79 - 80 - <!-- Contenu de la chronologie --> 81 - <div id="timeline-content"> 82 - {% include "bookmark_timeline.partial.html" %} 83 - </div> 84 - </div> 85 - </div> 86 - </div> 87 - 88 - <!-- Conteneur modal --> 89 - <div id="modal-container"></div> 4 + {% include 'bookmark_calendar_timeline.' + current_locale + '.common.html' %} 90 5 {% endblock %} 91 6 92 7 {% block scripts %}
+85
templates/bookmark_calendar_timeline.fr-ca.partial.html
··· 1 + <div class="bookmark-calendar-container"> 2 + <div class="columns"> 3 + <div class="column"> 4 + <!-- Timeline View --> 5 + <div class="timeline-container" id="timeline-container"> 6 + {% if events %} 7 + <div class="timeline"> 8 + {% for bookmarked_event in events %} 9 + <div class="timeline-item"> 10 + <div class="timeline-marker is-icon"> 11 + <i class="fas fa-bookmark" style="color: #ff6b6b;"></i> 12 + </div> 13 + <div class="timeline-content"> 14 + <div class="event-card box"> 15 + {% if bookmarked_event.bookmark.tags %} 16 + <div class="tags mb-3"> 17 + {% for tag in bookmarked_event.bookmark.tags %} 18 + <span class="tag is-primary is-small">{{ tag }}</span> 19 + {% endfor %} 20 + </div> 21 + {% endif %} 22 + 23 + <h5 class="title is-5">{{ bookmarked_event.event_details.name }}</h5> 24 + {% if bookmarked_event.event_details.description %} 25 + <p class="content">{{ bookmarked_event.event_details.description }}</p> 26 + {% endif %} 27 + 28 + {% if bookmarked_event.starts_at_human or bookmarked_event.event_details.starts_at %} 29 + <p class="has-text-grey"> 30 + <span class="icon"> 31 + <i class="fas fa-calendar"></i> 32 + </span> 33 + {{ t("event-date") }}: {{ bookmarked_event.starts_at_human | default(bookmarked_event.event_details.starts_at) }} 34 + </p> 35 + {% endif %} 36 + 37 + <div class="buttons"> 38 + <a href="/{{ bookmarked_event.event.did }}/{{ bookmarked_event.event_rkey }}" class="button is-small is-primary"> 39 + {{ t("view-event") }} 40 + </a> 41 + <button class="button is-small is-danger is-outlined" 42 + onclick="removeBookmarkFromTimeline(this)" 43 + data-event-aturi="{{ bookmarked_event.bookmark.event_aturi }}"> 44 + <span class="icon"> 45 + <i class="fas fa-trash"></i> 46 + </span> 47 + <span>{{ t("remove-bookmark") }}</span> 48 + </button> 49 + </div> 50 + </div> 51 + </div> 52 + </div> 53 + {% endfor %} 54 + </div> 55 + {% else %} 56 + <div class="has-text-centered py-6"> 57 + <span class="icon is-large has-text-grey-light"> 58 + <i class="fas fa-calendar-times fa-3x"></i> 59 + </span> 60 + <p class="title is-4 has-text-grey">{{ t("no-bookmarked-events") }}</p> 61 + <p class="subtitle has-text-grey">{{ t("start-bookmarking-events") }}</p> 62 + </div> 63 + {% endif %} 64 + </div> 65 + </div> 66 + <div class="column"> 67 + <div class="is-pulled-right"> 68 + {% include 'mini_calendar.html' %} 69 + <!-- Pass events data to mini calendar --> 70 + <script> 71 + window.bookmarkedEventDates = [ 72 + {% if events %} 73 + {% for bookmarked_event in events %} 74 + {% if bookmarked_event.event_details.starts_at %} 75 + '{{ bookmarked_event.event_details.starts_at }}'.split('T')[0], 76 + {% endif %} 77 + {% endfor %} 78 + {% endif %} 79 + ]; 80 + console.log('Setting bookmarked dates:', window.bookmarkedEventDates); 81 + </script> 82 + </div> 83 + </div> 84 + </div> 85 + </div>
+5
templates/bookmark_calendars_index.en-us.bare.html
··· 1 + {% extends "bare.en-us.html" %} 2 + 3 + {% block content %} 4 + {% include 'bookmark_calendars_index.' + current_locale + '.common.html' %} 5 + {% endblock %}
+34
templates/bookmark_calendars_index.en-us.common.html
··· 1 + <section class="section"> 2 + <div class="container"> 3 + <div class="columns"> 4 + <div class="column"> 5 + <h1 class="title"> 6 + <span class="icon"> 7 + <i class="fas fa-bookmark"></i> 8 + </span> 9 + {{ t("nav-my-calendars") }} 10 + </h1> 11 + <p class="subtitle">{{ t("bookmark-calendars-subtitle") }}</p> 12 + 13 + <!-- Create New Calendar Button --> 14 + <div class="field"> 15 + <div class="control"> 16 + <button class="button is-primary" onclick="openListModal()"> 17 + <span class="icon"> 18 + <i class="fas fa-plus"></i> 19 + </span> 20 + <span>{{ t("create-calendar") }}</span> 21 + </button> 22 + </div> 23 + </div> 24 + 25 + {% include 'bookmark_calendars_index.' + current_locale + '.partial.html' %} 26 + </div> 27 + </div> 28 + </div> 29 + </section> 30 + 31 + <!-- Create Calendar Modal Container --> 32 + <div id="calendar-modal-container"> 33 + {% include 'bookmark_list_modal.' + current_locale + '.partial.html' %} 34 + </div>
+7
templates/bookmark_calendars_index.en-us.html
··· 1 + {% extends "base.en-us.html" %} 2 + 3 + {% block title %}{{ t("nav-my-calendars") }} - {{ t("page-title-home") }}{% endblock %} 4 + 5 + {% block content %} 6 + {% include 'bookmark_calendars_index.' + current_locale + '.common.html' %} 7 + {% endblock %}
+83
templates/bookmark_calendars_index.en-us.partial.html
··· 1 + <!-- User's Calendars --> 2 + <div id="calendars-container"> 3 + {% if calendars %} 4 + {% for calendar in calendars %} 5 + <div class="card mb-4"> 6 + <div class="card-content"> 7 + <div class="media"> 8 + <div class="media-left"> 9 + <span class="icon is-large"> 10 + <i class="fas fa-calendar fa-2x"></i> 11 + </span> 12 + </div> 13 + <div class="media-content"> 14 + <a href="/bookmark-calendars/{{ calendar.calendar_id }}" class="has-text-dark"> 15 + <p class="title is-4">{{ calendar.name }}</p> 16 + {% if calendar.description %} 17 + <p class="subtitle is-6">{{ calendar.description }}</p> 18 + {% endif %} 19 + </a> 20 + <div class="tags"> 21 + {% for tag in calendar.tags %} 22 + <span class="tag is-primary">{{ tag }}</span> 23 + {% endfor %} 24 + </div> 25 + <p class="has-text-grey"> 26 + <span class="icon"> 27 + <i class="fas fa-calendar-check"></i> 28 + </span> 29 + {{ calendar.event_count }} {{ t("events") }} 30 + {% if calendar.is_public %} 31 + <span class="tag is-info is-light ml-2">{{ t("public") }}</span> 32 + {% endif %} 33 + </p> 34 + </div> 35 + <div class="media-right"> 36 + <div class="dropdown is-right"> 37 + <div class="dropdown-trigger"> 38 + <button class="button is-small" aria-haspopup="true" onclick="toggleDropdown(this)"> 39 + <span class="icon"> 40 + <i class="fas fa-ellipsis-v"></i> 41 + </span> 42 + </button> 43 + </div> 44 + <div class="dropdown-menu"> 45 + <div class="dropdown-content"> 46 + <a href="/bookmark-calendars/{{ calendar.calendar_id }}" class="dropdown-item"> 47 + <span class="icon"> 48 + <i class="fas fa-eye"></i> 49 + </span> 50 + {{ t("view-calendar") }} 51 + </a> 52 + <a href="/bookmark-calendars/{{ calendar.calendar_id }}.ics" class="dropdown-item"> 53 + <span class="icon"> 54 + <i class="fas fa-download"></i> 55 + </span> 56 + {{ t("export-calendar") }} 57 + </a> 58 + <hr class="dropdown-divider"> 59 + <a class="dropdown-item has-text-danger" 60 + hx-delete="/bookmark-calendars/{{ calendar.calendar_id }}" 61 + hx-target="closest .card" 62 + hx-swap="outerHTML" 63 + hx-confirm="{{ t('confirm-delete-calendar') }}"> 64 + <span class="icon"> 65 + <i class="fas fa-trash"></i> 66 + </span> 67 + {{ t("delete-calendar") }} 68 + </a> 69 + </div> 70 + </div> 71 + </div> 72 + </div> 73 + </div> 74 + </div> 75 + </div> 76 + {% endfor %} 77 + {% else %} 78 + <div class="notification is-info"> 79 + <p>{{ t("no-calendars-yet") }}</p> 80 + <p>{{ t("create-first-calendar-help") }}</p> 81 + </div> 82 + {% endif %} 83 + </div>
+5
templates/bookmark_calendars_index.fr-ca.bare.html
··· 1 + {% extends "bare.fr-ca.html" %} 2 + 3 + {% block content %} 4 + {% include 'bookmark_calendars_index.' + current_locale + '.common.html' %} 5 + {% endblock %}
+34
templates/bookmark_calendars_index.fr-ca.common.html
··· 1 + <section class="section"> 2 + <div class="container"> 3 + <div class="columns"> 4 + <div class="column"> 5 + <h1 class="title"> 6 + <span class="icon"> 7 + <i class="fas fa-bookmark"></i> 8 + </span> 9 + {{ t("nav-my-calendars") }} 10 + </h1> 11 + <p class="subtitle">{{ t("bookmark-calendars-subtitle") }}</p> 12 + 13 + <!-- Create New Calendar Button --> 14 + <div class="field"> 15 + <div class="control"> 16 + <button class="button is-primary" onclick="openListModal()"> 17 + <span class="icon"> 18 + <i class="fas fa-plus"></i> 19 + </span> 20 + <span>{{ t("create-calendar") }}</span> 21 + </button> 22 + </div> 23 + </div> 24 + 25 + {% include 'bookmark_calendars_index.' + current_locale + '.partial.html' %} 26 + </div> 27 + </div> 28 + </div> 29 + </section> 30 + 31 + <!-- Create Calendar Modal Container --> 32 + <div id="calendar-modal-container"> 33 + {% include 'bookmark_list_modal.' + current_locale + '.partial.html' %} 34 + </div>
+7
templates/bookmark_calendars_index.fr-ca.html
··· 1 + {% extends "base.fr-ca.html" %} 2 + 3 + {% block title %}{{ t("nav-my-calendars") }} - {{ t("page-title-home") }}{% endblock %} 4 + 5 + {% block content %} 6 + {% include 'bookmark_calendars_index.' + current_locale + '.common.html' %} 7 + {% endblock %}
+83
templates/bookmark_calendars_index.fr-ca.partial.html
··· 1 + <!-- User's Calendars --> 2 + <div id="calendars-container"> 3 + {% if calendars %} 4 + {% for calendar in calendars %} 5 + <div class="card mb-4"> 6 + <div class="card-content"> 7 + <div class="media"> 8 + <div class="media-left"> 9 + <span class="icon is-large"> 10 + <i class="fas fa-calendar fa-2x"></i> 11 + </span> 12 + </div> 13 + <div class="media-content"> 14 + <a href="/bookmark-calendars/{{ calendar.calendar_id }}" class="has-text-dark"> 15 + <p class="title is-4">{{ calendar.name }}</p> 16 + {% if calendar.description %} 17 + <p class="subtitle is-6">{{ calendar.description }}</p> 18 + {% endif %} 19 + </a> 20 + <div class="tags"> 21 + {% for tag in calendar.tags %} 22 + <span class="tag is-primary">{{ tag }}</span> 23 + {% endfor %} 24 + </div> 25 + <p class="has-text-grey"> 26 + <span class="icon"> 27 + <i class="fas fa-calendar-check"></i> 28 + </span> 29 + {{ calendar.event_count }} {{ t("events") }} 30 + {% if calendar.is_public %} 31 + <span class="tag is-info is-light ml-2">{{ t("public") }}</span> 32 + {% endif %} 33 + </p> 34 + </div> 35 + <div class="media-right"> 36 + <div class="dropdown is-right"> 37 + <div class="dropdown-trigger"> 38 + <button class="button is-small" aria-haspopup="true" onclick="toggleDropdown(this)"> 39 + <span class="icon"> 40 + <i class="fas fa-ellipsis-v"></i> 41 + </span> 42 + </button> 43 + </div> 44 + <div class="dropdown-menu"> 45 + <div class="dropdown-content"> 46 + <a href="/bookmark-calendars/{{ calendar.calendar_id }}" class="dropdown-item"> 47 + <span class="icon"> 48 + <i class="fas fa-eye"></i> 49 + </span> 50 + {{ t("view-calendar") }} 51 + </a> 52 + <a href="/bookmark-calendars/{{ calendar.calendar_id }}.ics" class="dropdown-item"> 53 + <span class="icon"> 54 + <i class="fas fa-download"></i> 55 + </span> 56 + {{ t("export-calendar") }} 57 + </a> 58 + <hr class="dropdown-divider"> 59 + <a class="dropdown-item has-text-danger" 60 + hx-delete="/bookmark-calendars/{{ calendar.calendar_id }}" 61 + hx-target="closest .card" 62 + hx-swap="outerHTML" 63 + hx-confirm="{{ t('confirm-delete-calendar') }}"> 64 + <span class="icon"> 65 + <i class="fas fa-trash"></i> 66 + </span> 67 + {{ t("delete-calendar") }} 68 + </a> 69 + </div> 70 + </div> 71 + </div> 72 + </div> 73 + </div> 74 + </div> 75 + </div> 76 + {% endfor %} 77 + {% else %} 78 + <div class="notification is-info"> 79 + <p>{{ t("no-calendars-yet") }}</p> 80 + <p>{{ t("create-first-calendar-help") }}</p> 81 + </div> 82 + {% endif %} 83 + </div>
+66
templates/bookmark_list_modal.en-us.partial.html
··· 1 + <div class="modal" id="create-list-modal"> 2 + <div class="modal-background"></div> 3 + <div class="modal-card"> 4 + <header class="modal-card-head"> 5 + <p class="modal-card-title">{{ t("create-bookmark-list") }}</p> 6 + <button class="delete" onclick="closeListModal()"></button> 7 + </header> 8 + <section class="modal-card-body"> 9 + <form id="create-list-form"> 10 + <div class="field"> 11 + <label class="label">{{ t("list-name") }}</label> 12 + <div class="control"> 13 + <input class="input" type="text" name="name" 14 + placeholder="{{ t('list-name-placeholder') }}" required> 15 + </div> 16 + <p class="help">{{ t("list-name-help") }}</p> 17 + </div> 18 + 19 + <div class="field"> 20 + <label class="label">{{ t("list-tags") }}</label> 21 + <div class="control"> 22 + <div class="tags-input-container"> 23 + <div id="selected-list-tags" class="tags-display"></div> 24 + <input class="input" type="text" id="new-list-tag-input" 25 + placeholder="{{ t('add-tag-placeholder') }}" 26 + onkeypress="handleListTagInput(event)"> 27 + </div> 28 + </div> 29 + <p class="help">{{ t("list-tags-help") }}</p> 30 + </div> 31 + 32 + <div class="field"> 33 + <label class="label">{{ t("list-description") }}</label> 34 + <div class="control"> 35 + <textarea class="textarea" name="description" 36 + placeholder="{{ t('list-description-placeholder') }}"></textarea> 37 + </div> 38 + </div> 39 + 40 + <div class="field"> 41 + <div class="control"> 42 + <label class="checkbox"> 43 + <input type="checkbox" name="is_public"> 44 + {{ t("make-calendar-public") }} 45 + </label> 46 + </div> 47 + <p class="help">{{ t("public-calendar-help") }}</p> 48 + <div class="notification is-info is-light"> 49 + <p class="is-size-7"> 50 + <strong>{{ t("atproto-privacy-notice") }}</strong><br> 51 + {{ t("atproto-privacy-explanation") }} 52 + </p> 53 + </div> 54 + </div> 55 + </form> 56 + </section> 57 + <footer class="modal-card-foot"> 58 + <button class="button is-primary" onclick="submitListForm()"> 59 + {{ t("create-list") }} 60 + </button> 61 + <button class="button" onclick="closeListModal()"> 62 + {{ t("cancel") }} 63 + </button> 64 + </footer> 65 + </div> 66 + </div>
+66
templates/bookmark_list_modal.fr-ca.partial.html
··· 1 + <div class="modal" id="create-list-modal"> 2 + <div class="modal-background"></div> 3 + <div class="modal-card"> 4 + <header class="modal-card-head"> 5 + <p class="modal-card-title">{{ t("create-bookmark-list") }}</p> 6 + <button class="delete" onclick="closeListModal()"></button> 7 + </header> 8 + <section class="modal-card-body"> 9 + <form id="create-list-form"> 10 + <div class="field"> 11 + <label class="label">{{ t("list-name") }}</label> 12 + <div class="control"> 13 + <input class="input" type="text" name="name" 14 + placeholder="{{ t('list-name-placeholder') }}" required> 15 + </div> 16 + <p class="help">{{ t("list-name-help") }}</p> 17 + </div> 18 + 19 + <div class="field"> 20 + <label class="label">{{ t("list-tags") }}</label> 21 + <div class="control"> 22 + <div class="tags-input-container"> 23 + <div id="selected-list-tags" class="tags-display"></div> 24 + <input class="input" type="text" id="new-list-tag-input" 25 + placeholder="{{ t('add-tag-placeholder') }}" 26 + onkeypress="handleListTagInput(event)"> 27 + </div> 28 + </div> 29 + <p class="help">{{ t("list-tags-help") }}</p> 30 + </div> 31 + 32 + <div class="field"> 33 + <label class="label">{{ t("list-description") }}</label> 34 + <div class="control"> 35 + <textarea class="textarea" name="description" 36 + placeholder="{{ t('list-description-placeholder') }}"></textarea> 37 + </div> 38 + </div> 39 + 40 + <div class="field"> 41 + <div class="control"> 42 + <label class="checkbox"> 43 + <input type="checkbox" name="is_public"> 44 + {{ t("make-calendar-public") }} 45 + </label> 46 + </div> 47 + <p class="help">{{ t("public-calendar-help") }}</p> 48 + <div class="notification is-info is-light"> 49 + <p class="is-size-7"> 50 + <strong>{{ t("atproto-privacy-notice") }}</strong><br> 51 + {{ t("atproto-privacy-explanation") }} 52 + </p> 53 + </div> 54 + </div> 55 + </form> 56 + </section> 57 + <footer class="modal-card-foot"> 58 + <button class="button is-primary" onclick="submitListForm()"> 59 + {{ t("create-list") }} 60 + </button> 61 + <button class="button" onclick="closeListModal()"> 62 + {{ t("cancel") }} 63 + </button> 64 + </footer> 65 + </div> 66 + </div>
+4
templates/bookmark_modal.en-us.bare.html
··· 1 + 2 + {% block content %} 3 + {% include 'bookmark_modal.' + current_locale + '.partial.html' %} 4 + {% endblock %}
+80
templates/bookmark_modal.en-us.partial.html
··· 1 + <div class="modal-card" id="bookmark-modal" data-event-aturi="{{ event_aturi }}"{% if is_already_bookmarked and existing_bookmark.tags %} data-existing-tags="{{ existing_bookmark.tags | join(',') }}"{% endif %}> 2 + <header class="modal-card-head"> 3 + <p class="modal-card-title"> 4 + {% if is_already_bookmarked %} 5 + Update Bookmark 6 + {% else %} 7 + Bookmark Event 8 + {% endif %} 9 + </p> 10 + <button class="delete" aria-label="close" onclick="closeBookmarkModal()"></button> 11 + </header> 12 + <section class="modal-card-body"> 13 + {% if is_already_bookmarked %} 14 + <div class="notification is-info"> 15 + <p><strong>This event is already bookmarked!</strong></p> 16 + <p>Current tags: 17 + {% if existing_bookmark.tags %} 18 + {% for tag in existing_bookmark.tags %} 19 + <span class="tag is-light">{{ tag }}</span> 20 + {% endfor %} 21 + {% else %} 22 + <em>No tags</em> 23 + {% endif %} 24 + </p> 25 + </div> 26 + {% endif %} 27 + 28 + <div class="field"> 29 + <label class="label">Tags (optional)</label> 30 + <div class="control"> 31 + <input class="input" type="text" id="bookmark-tags" 32 + placeholder="e.g. concerts, local, summer" 33 + onkeypress="handleTagKeypress(event)"> 34 + </div> 35 + <p class="help"> 36 + {% if is_already_bookmarked %} 37 + Update tags to reorganize your bookmark 38 + {% else %} 39 + Add tags to organize your bookmarks (comma-separated) 40 + {% endif %} 41 + </p> 42 + </div> 43 + <div id="selected-tags" class="tags"></div> 44 + </section> 45 + <footer class="modal-card-foot"> 46 + <button class="button is-success" onclick="submitBookmark()"> 47 + <span class="icon"> 48 + <i class="fas fa-bookmark"></i> 49 + </span> 50 + <span> 51 + {% if is_already_bookmarked %} 52 + Update Bookmark 53 + {% else %} 54 + Bookmark Event 55 + {% endif %} 56 + </span> 57 + </button> 58 + {% if is_already_bookmarked %} 59 + <button class="button is-danger" onclick="removeBookmark()"> 60 + <span class="icon"> 61 + <i class="fas fa-bookmark-slash"></i> 62 + </span> 63 + <span>Remove Bookmark</span> 64 + </button> 65 + {% endif %} 66 + <button class="button" onclick="closeBookmarkModal()">Cancel</button> 67 + </footer> 68 + </div> 69 + 70 + <script> 71 + // Initialize with existing tags from data attribute 72 + document.addEventListener('DOMContentLoaded', function() { 73 + const modalCard = document.getElementById('bookmark-modal'); 74 + if (modalCard && modalCard.hasAttribute('data-existing-tags')) { 75 + const existingTagsStr = modalCard.getAttribute('data-existing-tags'); 76 + const existingTags = existingTagsStr ? existingTagsStr.split(',') : []; 77 + initializeBookmarkModal(existingTags); 78 + } 79 + }); 80 + </script>
+4
templates/bookmark_modal.fr-ca.bare.html
··· 1 + 2 + {% block content %} 3 + {% include 'bookmark_modal.' + current_locale + '.partial.html' %} 4 + {% endblock %}
+80
templates/bookmark_modal.fr-ca.partial.html
··· 1 + <div class="modal-card" id="bookmark-modal" data-event-aturi="{{ event_aturi }}"{% if is_already_bookmarked and existing_bookmark.tags %} data-existing-tags="{{ existing_bookmark.tags | join(',') }}"{% endif %}> 2 + <header class="modal-card-head"> 3 + <p class="modal-card-title"> 4 + {% if is_already_bookmarked %} 5 + Modifier le marque-page 6 + {% else %} 7 + Marquer l'événement 8 + {% endif %} 9 + </p> 10 + <button class="delete" aria-label="close" onclick="closeBookmarkModal()"></button> 11 + </header> 12 + <section class="modal-card-body"> 13 + {% if is_already_bookmarked %} 14 + <div class="notification is-info"> 15 + <p><strong>Cet événement est déjà marqué!</strong></p> 16 + <p>Étiquettes actuelles: 17 + {% if existing_bookmark.tags %} 18 + {% for tag in existing_bookmark.tags %} 19 + <span class="tag is-light">{{ tag }}</span> 20 + {% endfor %} 21 + {% else %} 22 + <em>Aucune étiquette</em> 23 + {% endif %} 24 + </p> 25 + </div> 26 + {% endif %} 27 + 28 + <div class="field"> 29 + <label class="label">Étiquettes (optionnel)</label> 30 + <div class="control"> 31 + <input class="input" type="text" id="bookmark-tags" 32 + placeholder="ex. concerts, local, été" 33 + onkeypress="handleTagKeypress(event)"> 34 + </div> 35 + <p class="help"> 36 + {% if is_already_bookmarked %} 37 + Modifiez les étiquettes pour réorganiser votre marque-page 38 + {% else %} 39 + Ajoutez des étiquettes pour organiser vos marque-pages (séparées par des virgules) 40 + {% endif %} 41 + </p> 42 + </div> 43 + <div id="selected-tags" class="tags"></div> 44 + </section> 45 + <footer class="modal-card-foot"> 46 + <button class="button is-success" onclick="submitBookmark()"> 47 + <span class="icon"> 48 + <i class="fas fa-bookmark"></i> 49 + </span> 50 + <span> 51 + {% if is_already_bookmarked %} 52 + Modifier le marque-page 53 + {% else %} 54 + Marquer l'événement 55 + {% endif %} 56 + </span> 57 + </button> 58 + {% if is_already_bookmarked %} 59 + <button class="button is-danger" onclick="removeBookmark()"> 60 + <span class="icon"> 61 + <i class="fas fa-bookmark-slash"></i> 62 + </span> 63 + <span>Retirer le marque-page</span> 64 + </button> 65 + {% endif %} 66 + <button class="button" onclick="closeBookmarkModal()">Annuler</button> 67 + </footer> 68 + </div> 69 + 70 + <script> 71 + // Initialize with existing tags from data attribute 72 + document.addEventListener('DOMContentLoaded', function() { 73 + const modalCard = document.getElementById('bookmark-modal'); 74 + if (modalCard && modalCard.hasAttribute('data-existing-tags')) { 75 + const existingTagsStr = modalCard.getAttribute('data-existing-tags'); 76 + const existingTags = existingTagsStr ? existingTagsStr.split(',') : []; 77 + initializeBookmarkModal(existingTags); 78 + } 79 + }); 80 + </script>
+93 -68
templates/mini_calendar.html
··· 1 1 <div class="calendar-mini"> 2 - <div class="calendar-nav"> 3 - <button class="button is-small" 4 - hx-get="/bookmarks/calendar-nav?year={{ year }}&month={{ month - 1 }}" 5 - hx-target="#mini-calendar"> 6 - <i class="fas fa-chevron-left"></i> 7 - </button> 8 - <span class="calendar-month-year"> 9 - {{ month_names[month - 1] }} {{ year }} 10 - </span> 11 - <button class="button is-small" 12 - hx-get="/bookmarks/calendar-nav?year={{ year }}&month={{ month + 1 }}" 13 - hx-target="#mini-calendar"> 14 - <i class="fas fa-chevron-right"></i> 15 - </button> 2 + <div class="field"> 3 + <label class="label">{{ t("select-date") | default("Select Date") }}</label> 4 + <div class="control"> 5 + <input 6 + id="mini-calendar-picker" 7 + type="date" 8 + class="input" 9 + style="display: none;"> 10 + </div> 16 11 </div> 12 + </div> 13 + 14 + <script> 15 + function initializeMiniCalendar() { 16 + // Initialize the Bulma Calendar 17 + const calendarElement = document.getElementById('mini-calendar-picker'); 18 + if (calendarElement && typeof bulmaCalendar !== 'undefined') { 19 + // Check if calendar is already initialized and destroy it 20 + if (calendarElement.bulmaCalendar) { 21 + calendarElement.bulmaCalendar.destroy(); 22 + } 23 + 24 + // Map current locale to determine language for labels 25 + const currentLocale = '{{ current_locale | default("fr-ca") }}'; 26 + const isFrench = currentLocale && currentLocale.startsWith('fr'); 27 + 28 + // Get bookmarked event dates for highlighting 29 + const bookmarkedDates = window.bookmarkedEventDates || []; 30 + console.log('Bookmarked dates for calendar:', bookmarkedDates); 31 + 32 + // Simple initialization with minimal options 33 + const calendar = new bulmaCalendar(calendarElement, { 34 + type: 'date', 35 + displayMode: 'inline', 36 + // Add French labels when language is French 37 + cancelLabel: isFrench ? 'Annuler' : 'Cancel', 38 + clearLabel: isFrench ? 'Effacer' : 'Clear', 39 + todayLabel: isFrench ? "Aujourd'hui" : 'Today', 40 + nowLabel: isFrench ? 'Maintenant' : 'Now', 41 + validateLabel: isFrench ? 'Valider' : 'Validate', 42 + // Callback to highlight dates after calendar is ready 43 + onReady: function(instance) { 44 + console.log('Calendar ready, highlighting bookmarked dates...'); 45 + highlightBookmarkedDates(bookmarkedDates, isFrench); 46 + } 47 + }); 48 + 49 + // Add event listener for date selection 50 + if (calendar) { 51 + calendar.on('select', function(date) { 52 + console.log('Date selected:', date); 53 + // You can add custom logic here when a date is selected 54 + // For example, trigger an HTMX request to update events 55 + // htmx.trigger('#some-element', 'dateChanged', {detail: {date: date}}); 56 + }); 57 + } 58 + } 59 + } 60 + 61 + // Function to highlight bookmarked dates 62 + function highlightBookmarkedDates(bookmarkedDates, isFrench) { 63 + if (!bookmarkedDates || bookmarkedDates.length === 0) return; 17 64 18 - <table class="table is-narrow is-fullwidth"> 19 - <thead> 20 - <tr> 21 - <th>Mo</th> 22 - <th>Tu</th> 23 - <th>We</th> 24 - <th>Th</th> 25 - <th>Fr</th> 26 - <th>Sa</th> 27 - <th>Su</th> 28 - </tr> 29 - </thead> 30 - <tbody> 31 - {% set month_start = date(year, month, 1) %} 32 - {% set start_weekday = month_start.weekday() %} 33 - {% set days_in_month = month_start.days_in_month() %} 34 - 35 - {% set current_day = 1 %} 36 - {% set week_count = 0 %} 37 - 38 - {% for week in range(6) %} 39 - {% if current_day <= days_in_month %} 40 - <tr> 41 - {% for day_of_week in range(7) %} 42 - {% if week == 0 and day_of_week < start_weekday %} 43 - <td></td> 44 - {% elif current_day <= days_in_month %} 45 - {% set has_events = false %} 46 - {% for event in events %} 47 - {% set event_date = event.event.start_time|date('Y-m-d') %} 48 - {% set current_date = year ~ '-' ~ month|string|pad(2, '0') ~ '-' ~ current_day|string|pad(2, '0') %} 49 - {% if event_date == current_date %} 50 - {% set has_events = true %} 51 - {% endif %} 52 - {% endfor %} 53 - 54 - <td class="{% if has_events %}has-background-primary-light{% endif %}"> 55 - <button class="button is-small is-white {% if has_events %}has-text-primary{% endif %}" 56 - hx-get="/bookmarks?start_date={{ year }}-{{ month|string|pad(2, '0') }}-{{ current_day|string|pad(2, '0') }}&end_date={{ year }}-{{ month|string|pad(2, '0') }}-{{ current_day|string|pad(2, '0') }}" 57 - hx-target="#timeline-content"> 58 - {{ current_day }} 59 - </button> 60 - </td> 61 - {% set current_day = current_day + 1 %} 62 - {% else %} 63 - <td></td> 64 - {% endif %} 65 - {% endfor %} 66 - </tr> 67 - {% endif %} 68 - {% endfor %} 69 - </tbody> 70 - </table> 71 - </div> 65 + bookmarkedDates.forEach(dateStr => { 66 + const targetDay = parseInt(dateStr.split('-')[2]).toString(); 67 + console.log('Highlighting day:', targetDay, 'from date:', dateStr); 68 + 69 + // Find date items in current month 70 + const dateItems = document.querySelectorAll('.datepicker-date.is-current-month .date-item'); 71 + 72 + dateItems.forEach((item) => { 73 + if (item.textContent.trim() === targetDay) { 74 + console.log('Found and highlighting day:', targetDay); 75 + item.classList.add('has-bookmark-event'); 76 + item.style.backgroundColor = '#ff6b6b'; 77 + item.style.color = 'white'; 78 + item.style.fontWeight = 'bold'; 79 + item.style.borderRadius = '50%'; 80 + item.title = isFrench ? 'Événement marqué' : 'Bookmarked event'; 81 + } 82 + }); 83 + }); 84 + } 85 + 86 + // Initialize on page load 87 + document.addEventListener('DOMContentLoaded', initializeMiniCalendar); 88 + 89 + // Reinitialize after HTMX updates 90 + document.addEventListener('htmx:afterSettle', function(event) { 91 + // Check if the updated content contains our calendar 92 + if (event.target.id === 'mini-calendar' || event.target.querySelector('#mini-calendar-picker')) { 93 + initializeMiniCalendar(); 94 + } 95 + }); 96 + </script>
+32 -3
templates/nav.en-us.html
··· 22 22 <a class="navbar-item" href="/" hx-boost="true"> 23 23 {{ t("nav-home") }} 24 24 </a> 25 - <a class="navbar-item" href="/events" hx-boost="true"> 26 - {{ t("nav-events") }} 27 - </a> 25 + <!-- Replace the existing events nav item with a dropdown --> 26 + <div class="navbar-item has-dropdown is-hoverable"> 27 + <a class="navbar-link"> 28 + <span class="icon"> 29 + <i class="fas fa-calendar"></i> 30 + </span> 31 + {{ t("nav-events-calendars") }} 32 + </a> 33 + <div class="navbar-dropdown"> 34 + <a class="navbar-item" href="/events" hx-boost="true"> 35 + <span class="icon"> 36 + <i class="fas fa-list"></i> 37 + </span> 38 + {{ t("nav-browse-events") }} 39 + </a> 40 + {% if current_handle %} 41 + <hr class="navbar-divider"> 42 + <a class="navbar-item" href="/bookmark-calendars" hx-boost="true"> 43 + <span class="icon"> 44 + <i class="fas fa-bookmark"></i> 45 + </span> 46 + {{ t("nav-my-calendars") }} 47 + </a> 48 + <a class="navbar-item" href="/bookmarks" hx-boost="true"> 49 + <span class="icon"> 50 + <i class="fas fa-heart"></i> 51 + </span> 52 + {{ t("nav-all-bookmarks") }} 53 + </a> 54 + {% endif %} 55 + </div> 56 + </div> 28 57 <a class="navbar-item" href="https://docs.{{ base | replace('https://', '') | replace('http://', '') }}/"> 29 58 {{ t("nav-help") }} 30 59 </a>
+32 -3
templates/nav.fr-ca.html
··· 22 22 <a class="navbar-item" href="/" hx-boost="true"> 23 23 {{ t("nav-home") }} 24 24 </a> 25 - <a class="navbar-item" href="/events" hx-boost="true"> 26 - {{ t("nav-events") }} 27 - </a> 25 + <!-- Replace the existing events nav item with a dropdown --> 26 + <div class="navbar-item has-dropdown is-hoverable"> 27 + <a class="navbar-link"> 28 + <span class="icon"> 29 + <i class="fas fa-calendar"></i> 30 + </span> 31 + {{ t("nav-events-calendars") }} 32 + </a> 33 + <div class="navbar-dropdown"> 34 + <a class="navbar-item" href="/events" hx-boost="true"> 35 + <span class="icon"> 36 + <i class="fas fa-list"></i> 37 + </span> 38 + {{ t("nav-browse-events") }} 39 + </a> 40 + {% if current_handle %} 41 + <hr class="navbar-divider"> 42 + <a class="navbar-item" href="/bookmark-calendars" hx-boost="true"> 43 + <span class="icon"> 44 + <i class="fas fa-bookmark"></i> 45 + </span> 46 + {{ t("nav-my-calendars") }} 47 + </a> 48 + <a class="navbar-item" href="/bookmarks" hx-boost="true"> 49 + <span class="icon"> 50 + <i class="fas fa-heart"></i> 51 + </span> 52 + {{ t("nav-all-bookmarks") }} 53 + </a> 54 + {% endif %} 55 + </div> 56 + </div> 28 57 <a class="navbar-item" href="https://docs.{{ base | replace('https://', '') | replace('http://', '') }}/"> 29 58 {{ t("nav-help") }} 30 59 </a>
+5
templates/profile.en-us.common.html
··· 30 30 </section> 31 31 <section class="section"> 32 32 <div class="container"> 33 + {% include 'profile_bookmark_lists.' + current_locale + '.incl.html' %} 34 + </div> 35 + </section> 36 + <section class="section"> 37 + <div class="container"> 33 38 <div class="tabs"> 34 39 <ul> 35 40 <li class="is-active"><a>{{ t("profile-recently-updated") }}</a></li>
+5
templates/profile.fr-ca.common.html
··· 30 30 </section> 31 31 <section class="section"> 32 32 <div class="container"> 33 + {% include 'profile_bookmark_lists.' + current_locale + '.incl.html' %} 34 + </div> 35 + </section> 36 + <section class="section"> 37 + <div class="container"> 33 38 <div class="tabs"> 34 39 <ul> 35 40 <li class="is-active"><a>{{ t("profile-recently-updated") }}</a></li>
+69
templates/profile_bookmark_lists.en-us.incl.html
··· 1 + <div class="profile-section"> 2 + <div class="section-header"> 3 + <h3 class="title is-4"> 4 + <i class="fas fa-bookmark"></i> 5 + {{ t("public-calendars") }} 6 + </h3> 7 + {% if is_self %} 8 + <a href="/bookmark-calendars" class="button is-primary is-small"> 9 + <i class="fas fa-plus"></i> 10 + {{ t("manage-calendars") }} 11 + </a> 12 + {% endif %} 13 + </div> 14 + 15 + {% if public_calendars %} 16 + <div class="bookmark-lists-grid"> 17 + {% for calendar in public_calendars %} 18 + <div class="list-card"> 19 + <div class="list-card-header"> 20 + <h4 class="list-name"> 21 + <a href="/bookmark-calendars/{{ calendar.calendar_id }}"> 22 + {{ calendar.name }} 23 + </a> 24 + </h4> 25 + <div class="list-stats"> 26 + <span class="tag is-light"> 27 + {{ calendar.event_count }} {{ t("events") }} 28 + </span> 29 + </div> 30 + </div> 31 + 32 + {% if calendar.description %} 33 + <p class="list-description">{{ calendar.description }}</p> 34 + {% endif %} 35 + 36 + {% if calendar.tags %} 37 + <div class="list-card-tags mb-3"> 38 + {% for tag in calendar.tags %} 39 + <span class="tag is-primary is-light">{{ tag }}</span> 40 + {% endfor %} 41 + </div> 42 + {% endif %} 43 + 44 + <div class="list-card-footer"> 45 + <small class="has-text-grey"> 46 + {{ t("created") }} {{ calendar.created_at | date(format="%B %d, %Y") }} 47 + </small> 48 + <div class="list-actions"> 49 + <a href="/bookmark-calendars/{{ calendar.calendar_id }}.ics" 50 + class="button is-small is-outlined" 51 + title="{{ t('export-calendar') }}"> 52 + <i class="fas fa-calendar-alt"></i> 53 + </a> 54 + <button class="button is-small is-outlined share-list-btn" 55 + data-list-url="/bookmark-calendars/{{ calendar.calendar_id }}" 56 + title="{{ t('share-calendar') }}"> 57 + <i class="fas fa-share"></i> 58 + </button> 59 + </div> 60 + </div> 61 + </div> 62 + {% endfor %} 63 + </div> 64 + {% else %} 65 + <div class="empty-state"> 66 + <p class="has-text-grey">{{ t("no-public-calendars") }}</p> 67 + </div> 68 + {% endif %} 69 + </div>
+59
templates/profile_bookmark_lists.fr-ca.incl.html
··· 1 + <div class="profile-section"> 2 + <div class="section-header"> 3 + <h3 class="title is-4"> 4 + <i class="fas fa-bookmark"></i> 5 + {{ t("public-calendars") }} 6 + </h3> 7 + {% if is_self %} 8 + <a href="/bookmark-calendars" class="button is-primary is-small"> 9 + <i class="fas fa-plus"></i> 10 + {{ t("manage-calendars") }} 11 + </a> 12 + {% endif %} 13 + </div> 14 + 15 + {% if public_calendars %} 16 + <div class="columns is-multiline"> 17 + {% for calendar in public_calendars %} 18 + <div class="column is-half"> 19 + <div class="card"> 20 + <div class="card-content"> 21 + <div class="media"> 22 + <div class="media-left"> 23 + <span class="icon is-large"> 24 + <i class="fas fa-calendar fa-2x"></i> 25 + </span> 26 + </div> 27 + <div class="media-content"> 28 + <h4 class="title is-5"> 29 + <a href="/bookmark-calendars/{{ calendar.calendar_id }}"> 30 + {{ calendar.name }} 31 + </a> 32 + </h4> 33 + {% if calendar.description %} 34 + <p class="subtitle is-6">{{ calendar.description }}</p> 35 + {% endif %} 36 + <div class="tags"> 37 + {% for tag in calendar.tags %} 38 + <span class="tag is-primary">{{ tag }}</span> 39 + {% endfor %} 40 + </div> 41 + <p class="has-text-grey"> 42 + <span class="icon"> 43 + <i class="fas fa-calendar-check"></i> 44 + </span> 45 + {{ calendar.event_count }} {{ t("events") }} 46 + </p> 47 + </div> 48 + </div> 49 + </div> 50 + </div> 51 + </div> 52 + {% endfor %} 53 + </div> 54 + {% else %} 55 + <div class="empty-state"> 56 + <p class="has-text-grey">{{ t("no-public-calendars") }}</p> 57 + </div> 58 + {% endif %} 59 + </div>
+76 -1
templates/view_event.en-us.common.html
··· 386 386 </article> 387 387 {% endif %} 388 388 {% endif %} 389 + 390 + <!-- Bookmark Section --> 391 + {% if current_handle and not is_legacy_event %} 392 + <div class="columns is-vcentered"> 393 + <div class="column"> 394 + <div id="bookmarkFrame"> 395 + {% if user_bookmark_status %} 396 + <article class="message is-info"> 397 + <div class="message-body"> 398 + <div class="columns is-vcentered"> 399 + <div class="column"> 400 + <span class="icon-text"> 401 + <span class="icon"> 402 + <i class="fas fa-bookmark"></i> 403 + </span> 404 + <span>Bookmarked{% if user_bookmark_tags %} with tags: {{ user_bookmark_tags | join(", ") }}{% endif %}</span> 405 + </span> 406 + </div> 407 + <div class="column is-narrow"> 408 + <button class="button is-outlined is-danger is-small" 409 + hx-delete="/bookmarks/{{ user_bookmark_aturi | urlencode }}" 410 + hx-target="#bookmarkFrame" 411 + hx-swap="outerHTML" 412 + hx-confirm="Are you sure you want to remove this bookmark?"> 413 + <span class="icon"> 414 + <i class="fas fa-times"></i> 415 + </span> 416 + <span>Remove Bookmark</span> 417 + </button> 418 + </div> 419 + </div> 420 + </div> 421 + </article> 422 + {% else %} 423 + <article class="message is-light"> 424 + <div class="message-body"> 425 + <div class="columns is-vcentered"> 426 + <div class="column"> 427 + <span class="icon-text"> 428 + <span class="icon"> 429 + <i class="far fa-bookmark"></i> 430 + </span> 431 + <span>Bookmark this event to add it to your personal calendar</span> 432 + </span> 433 + </div> 434 + <div class="column is-narrow"> 435 + <button class="button is-primary is-small" 436 + hx-get="/bookmarks/modal?event_aturi={{ event.aturi | urlencode }}" 437 + hx-target="#bookmarkModal" 438 + hx-swap="innerHTML" 439 + onclick="document.getElementById('bookmarkModal').classList.add('is-active')"> 440 + <span class="icon"> 441 + <i class="fas fa-bookmark"></i> 442 + </span> 443 + <span>Bookmark Event</span> 444 + </button> 445 + </div> 446 + </div> 447 + </div> 448 + </article> 449 + {% endif %} 450 + </div> 451 + </div> 452 + </div> 453 + {% endif %} 454 + <!-- End Bookmark Section --> 389 455 </div> 390 456 </section> 391 457 ··· 459 525 </div> 460 526 {% endif %} 461 527 </div> 462 - </section> 528 + </section> 529 + 530 + <!-- Bookmark Modal --> 531 + <div class="modal" id="bookmarkModal"> 532 + <div class="modal-background" onclick="document.getElementById('bookmarkModal').classList.remove('is-active')"></div> 533 + <div class="modal-content"> 534 + <!-- Modal content will be loaded via HTMX --> 535 + </div> 536 + <button class="modal-close is-large" aria-label="close" onclick="document.getElementById('bookmarkModal').classList.remove('is-active')"></button> 537 + </div>
+75
templates/view_event.fr-ca.common.html
··· 299 299 {% endif %} 300 300 {% endif %} 301 301 302 + <!-- Section marque-pages --> 303 + {% if current_handle and not is_legacy_event %} 304 + <div class="columns is-vcentered"> 305 + <div class="column"> 306 + <div id="bookmarkFrame"> 307 + {% if user_bookmark_status %} 308 + <article class="message is-info"> 309 + <div class="message-body"> 310 + <div class="columns is-vcentered"> 311 + <div class="column"> 312 + <span class="icon-text"> 313 + <span class="icon"> 314 + <i class="fas fa-bookmark"></i> 315 + </span> 316 + <span>Marqué{% if user_bookmark_tags %} avec étiquettes: {{ user_bookmark_tags | join(", ") }}{% endif %}</span> 317 + </span> 318 + </div> 319 + <div class="column is-narrow"> 320 + <button class="button is-outlined is-danger is-small" 321 + hx-delete="/bookmarks/{{ user_bookmark_aturi | urlencode }}" 322 + hx-target="#bookmarkFrame" 323 + hx-swap="outerHTML" 324 + hx-confirm="Êtes-vous sûr·e de vouloir retirer ce marque-page?"> 325 + <span class="icon"> 326 + <i class="fas fa-times"></i> 327 + </span> 328 + <span>Retirer le marque-page</span> 329 + </button> 330 + </div> 331 + </div> 332 + </div> 333 + </article> 334 + {% else %} 335 + <article class="message is-light"> 336 + <div class="message-body"> 337 + <div class="columns is-vcentered"> 338 + <div class="column"> 339 + <span class="icon-text"> 340 + <span class="icon"> 341 + <i class="far fa-bookmark"></i> 342 + </span> 343 + <span>Marquez cet événement pour l'ajouter à votre calendrier personnel</span> 344 + </span> 345 + </div> 346 + <div class="column is-narrow"> 347 + <button class="button is-primary is-small" 348 + hx-get="/bookmarks/modal?event_aturi={{ event.aturi | urlencode }}" 349 + hx-target="#bookmarkModal" 350 + hx-swap="innerHTML" 351 + onclick="document.getElementById('bookmarkModal').classList.add('is-active')"> 352 + <span class="icon"> 353 + <i class="fas fa-bookmark"></i> 354 + </span> 355 + <span>Marquer l'événement</span> 356 + </button> 357 + </div> 358 + </div> 359 + </div> 360 + </article> 361 + {% endif %} 362 + </div> 363 + </div> 364 + </div> 365 + {% endif %} 366 + <!-- Fin section marque-pages --> 367 + 302 368 <!-- Layout principal: 2 colonnes --> 303 369 <div class="columns is-desktop"> 304 370 <!-- Colonne principale (gauche) --> ··· 624 690 </div> 625 691 </div> 626 692 </section> 693 + 694 + <!-- Modal marque-pages --> 695 + <div class="modal" id="bookmarkModal"> 696 + <div class="modal-background" onclick="document.getElementById('bookmarkModal').classList.remove('is-active')"></div> 697 + <div class="modal-content"> 698 + <!-- Le contenu du modal sera chargé via HTMX --> 699 + </div> 700 + <button class="modal-close is-large" aria-label="close" onclick="document.getElementById('bookmarkModal').classList.remove('is-active')"></button> 701 + </div>