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

Second pass to the implementation of bookmarks.

crudely works, but needs more testing and polish.

kayrozen 9c4fc5ca 38616e08

Changed files
+3037 -394
i18n
lexicons
migrations
scripts
src
static
templates
+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>