+13
-6
i18n/en-us/bookmarks.ftl
+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
+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
+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
+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
+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
+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
lexicons/dev_tunn_plaque_bookmarks_schema.json
This is a binary file and will not be displayed.
+4
migrations/20250619000000_remove_bookmark_fkey.sql
+4
migrations/20250619000000_remove_bookmark_fkey.sql
scripts/publish_lexicons.sh
scripts/publish_lexicons.sh
This is a binary file and will not be displayed.
scripts/register_bookmark_lexicon.sh
scripts/register_bookmark_lexicon.sh
This is a binary file and will not be displayed.
+69
-5
src/atproto/client.rs
+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
+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
src/atproto/lexicon/mod.rs
+20
-23
src/http/handle_bookmark_calendars.rs
+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, ¤t_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
+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(¤t_handle.did, ¶ms.event_aturi, tags)
143
+
.get_bookmark(¤t_handle.did, ¶ms.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(¤t_handle.did, ¶ms.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(¤t_handle.did, ¶ms.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(¤t_handle.did, &bookmark_aturi)
344
+
.remove_bookmark(¤t_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(¤t_handle.did, ¶ms.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(¤t_handle),
515
+
"/bookmarks/modal",
516
+
))
517
+
}
+1
-1
src/http/handle_create_event.rs
+1
-1
src/http/handle_create_event.rs
+1
-1
src/http/handle_create_rsvp.rs
+1
-1
src/http/handle_create_rsvp.rs
+1
-1
src/http/handle_edit_event.rs
+1
-1
src/http/handle_edit_event.rs
+1
-1
src/http/handle_migrate_event.rs
+1
-1
src/http/handle_migrate_event.rs
+1
-1
src/http/handle_migrate_rsvp.rs
+1
-1
src/http/handle_migrate_rsvp.rs
+10
src/http/handle_profile.rs
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+4
templates/bookmark_calendar_timeline.en-us.bare.html
+53
templates/bookmark_calendar_timeline.en-us.common.html
+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
+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
+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
+4
templates/bookmark_calendar_timeline.fr-ca.bare.html
+35
templates/bookmark_calendar_timeline.fr-ca.common.html
+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
+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
+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
+5
templates/bookmark_calendars_index.en-us.bare.html
+34
templates/bookmark_calendars_index.en-us.common.html
+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
+7
templates/bookmark_calendars_index.en-us.html
+83
templates/bookmark_calendars_index.en-us.partial.html
+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
+5
templates/bookmark_calendars_index.fr-ca.bare.html
+34
templates/bookmark_calendars_index.fr-ca.common.html
+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
+7
templates/bookmark_calendars_index.fr-ca.html
+83
templates/bookmark_calendars_index.fr-ca.partial.html
+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
+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
+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
+4
templates/bookmark_modal.en-us.bare.html
+80
templates/bookmark_modal.en-us.partial.html
+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
+4
templates/bookmark_modal.fr-ca.bare.html
+80
templates/bookmark_modal.fr-ca.partial.html
+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
+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>
+5
templates/profile.en-us.common.html
+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
+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
+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
+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
+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
+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>