Bookmark Event Calendar Implementation Plan#
Based on the smokesignal architecture and the ATproto bookmark lexicon, here's a comprehensive implementation plan for event bookmark lists with custom calendars.
Overview#
The feature enables users to bookmark events from community.lexicon.calendar.event directly by their AT-URI and organize them using tags. Events are displayed in a timeline view with an integrated mini-calendar component, built with HTMX and using the existing ATproto infrastructure.
Data Consistency Strategy: Full ATproto source-of-truth with local caching for performance. Local storage serves only as a performance optimization layer, with ATproto as the authoritative data source.
Bookmark Model: Bookmarks reference event AT-URIs directly (e.g., at://did:plc:xyz/community.lexicon.calendar.event/abc123), not posts containing events. This allows compatibility with other platforms.
Organization: Custom lists are implemented using ATproto's tag-based filtering via getActorBookmarks rather than separate list records.
Architecture Clarification: Bookmark Calendars vs View Modes#
Core Concept: Separate data organization (bookmark calendars) from presentation modes (view types).
Bookmark Calendars: Named collections of bookmarked events (e.g., "Summer Festivals", "Work Events")
- User-facing term: "Calendar" (e.g., "My Summer Festivals Calendar")
- Stored as
bookmark_calendarswith tag-based filtering rules - Each calendar has metadata: name, description, tags, platform privacy settings
- Calendars are the organizational unit users create and manage
- Implementation detail: These are collections/lists, not calendar grids
View Modes: Different ways to display any bookmark calendar
- Timeline View: Chronological list with mini-calendar widget (default)
- Calendar Grid View: Traditional month/week calendar layout
- List View: Simple table format (future enhancement)
- Views are presentation choices, not separate data entities
User Experience:
- Users create and manage "Calendars"
- Users can view their calendars in "Timeline" or "Calendar Grid" mode
- The default view for individual calendars is Timeline
- The default view for "All Bookmarks" is Calendar Grid
Implementation Strategy:
- Data Layer:
bookmark_calendarstable stores calendar definitions with tag rules - Presentation Layer: Multiple template sets for each view mode
- URL Structure:
/bookmark-calendars/{calendar_id}?view={timeline|calendar|list} - Default Behavior: Timeline view for individual calendars, calendar grid for "All Bookmarks"
User Scenarios Analysis#
Scenario 1: Music Festival Enthusiast#
User: Sarah, follows multiple music venues and artists#
Goal: Track upcoming concerts and festivals in her area#
- Sarah sees a concert announcement on Bluesky
- She bookmarks it with tags: #concerts, #local, #rock
- She creates a "Summer Festivals" list using the #festivals tag
- Sarah views her calendar showing all bookmarked events in timeline format
- She filters by #local to see only nearby events
- The mini-calendar highlights dates with events
Scenario 2: Community Event Organizer#
User: Marcus, community center coordinator#
Goal: Track local community events and plan around them#
- Marcus follows local organizations posting events
- He bookmarks relevant posts with tags: #community, #workshops, #family-friendly
- He creates custom calendars: "Kids Events", "Adult Workshops", "Seasonal Celebrations"
- Timeline view helps him avoid scheduling conflicts
- He shares his curated event calendars with community members
Scenario 3: Professional dance music event promoter#
User : Mark, nightclub promoter#
Goal : Schedule and promote his events#
- He bookmarks relevant events with tags: #techno, #drumandbass, #allages
- He creates custom calendars: "City wide festival", "Dance Music Workshops", "Seasonal Festival"
- Timeline view helps him avoid scheduling conflicts and promote his brand
- He shares his curated event calendars on social media.
Design Decisions & Clarifications#
1. ATproto Lexicon Strategy
The community.lexicon.bookmarks lexicon is part of a community standardization effort for NSIDs. This provides a solid foundation for interoperability across ATproto applications.
2. Sync Strategy Purpose The sync strategy ensures data consistency between ATproto (source of truth) and local cache (performance optimization). Sync is triggered:
- On first page load (if cache is stale)
- When explicitly requested by user
- After bookmark operations to refresh local state
3. User Experience Approach
- Tag Management: Simple text-based tags, user-created without autocomplete for flexibility
- Bulk Operations: Not implemented initially to keep scope manageable
- ATproto Privacy Model: All bookmark data (events, tags, timestamps) is public on ATproto network by design
- Platform Privacy: Calendar visibility settings only control discovery within smokesignal, not ATproto visibility
- Sharing: All ATproto data is public, enabling natural calendar sharing through tag filtering
4. Performance Strategy
- Timeline Pagination: Essential for handling large bookmark collections
- Calendar Widget: Fetch only events for the current displayed month/period
- Timezone Handling: Leverage existing user timezone and event timezone data
5. Error Handling Strategy
- Deleted Events: Handle gracefully (similar to existing RSVP system approach)
- Network Failures: Implement retry logic and graceful degradation to cached data
- Malformed Data: Leverage existing event validation patterns
6. System Integration
- RSVP Independence: Bookmarks and RSVPs remain separate systems
- No Cross-Influence: Bookmarks don't affect recommendations or RSVP workflows initially
- Future Calendar Export: iCal feed generation for external calendar integration
7. Privacy Model Clarification
- ATproto Network: All bookmark data (events, tags, timestamps) is inherently public on ATproto
- Platform Privacy: Calendar "privacy" settings only control visibility within smokesignal platform
- User Transparency: Clear notices inform users that their bookmark data is always discoverable on ATproto
- Design Philosophy: Embrace ATproto's public nature while providing platform-level organization controls
Core Architecture#
1. Database Schema Extensions#
Design Note: We store both bookmark_aturi (ATproto record URI) and event_aturi (event being bookmarked) for different purposes:
bookmark_aturi: Required for ATproto operations (delete, update bookmark record)event_aturi: Required for local queries and joins with events table- This dual storage enables efficient local operations while maintaining ATproto record management
New Tables:
-- Event bookmarks table (local cache for performance)
CREATE TABLE event_bookmarks (
id SERIAL PRIMARY KEY,
did VARCHAR(256) NOT NULL,
bookmark_aturi VARCHAR(1024) NOT NULL, -- ATproto bookmark record URI
event_aturi VARCHAR(1024) NOT NULL, -- Direct event AT-URI being bookmarked
tags TEXT[] NOT NULL DEFAULT '{}',
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
synced_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), -- Last sync with ATproto
FOREIGN KEY (event_aturi) REFERENCES events(aturi) ON DELETE CASCADE,
UNIQUE(did, bookmark_aturi), -- Prevent duplicate bookmark records
UNIQUE(did, event_aturi) -- Prevent duplicate event bookmarks
);
-- Remove bookmark_lists table - using ATproto tags instead
-- Enhanced schema for bookmark lists (user-created collections)
CREATE TABLE bookmark_lists (
id SERIAL PRIMARY KEY,
did VARCHAR(256) NOT NULL,
list_id VARCHAR(64) NOT NULL, -- Unique identifier like "summer-festivals-2025"
name VARCHAR(255) NOT NULL, -- User-friendly name like "Summer Festivals"
description TEXT,
tags TEXT[] NOT NULL DEFAULT '{}', -- Multiple tags that define this list
tag_operator VARCHAR(5) NOT NULL DEFAULT 'OR' CHECK(tag_operator IN ('AND', 'OR')), -- How tags are combined
is_public BOOLEAN NOT NULL DEFAULT false, -- Controls smokesignal platform visibility only; ATproto data is always public
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
UNIQUE(did, list_id), -- Prevent duplicate list IDs for user
CHECK(array_length(tags, 1) > 0) -- Ensure at least one tag per list
);
-- Additional indexes for bookmark lists
CREATE INDEX idx_bookmark_lists_did ON bookmark_lists(did);
CREATE INDEX idx_bookmark_lists_public ON bookmark_lists(is_public) WHERE is_public = true;
CREATE INDEX idx_bookmark_lists_tags ON bookmark_lists USING GIN(tags); -- GIN index for array queries
2. ATproto Integration Layer#
Bookmark Lexicon Implementation:
// src/atproto/lexicon/community_lexicon_bookmarks.rs
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
pub const BOOKMARK_NSID: &str = "community.lexicon.bookmarks.bookmark";
pub const GET_BOOKMARKS_NSID: &str = "community.lexicon.bookmarks.getActorBookmarks";
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Bookmark {
pub subject: String, // Event AT-URI (e.g., at://did:plc:xyz/community.lexicon.calendar.event/abc123)
#[serde(rename = "createdAt")]
pub created_at: DateTime<Utc>,
pub tags: Vec<String>, // Tags are required for organization
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GetActorBookmarksParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub tags: Option<Vec<String>>, // Filter by specific tags
#[serde(skip_serializing_if = "Option::is_none")]
pub limit: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cursor: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GetActorBookmarksResponse {
pub bookmarks: Vec<BookmarkRecord>, // Returns full bookmark records with AT-URIs
#[serde(skip_serializing_if = "Option::is_none")]
pub cursor: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct BookmarkRecord {
pub uri: String, // AT-URI of the bookmark record itself
pub cid: String,
pub value: Bookmark, // The bookmark data
#[serde(rename = "indexedAt")]
pub indexed_at: DateTime<Utc>,
}
3. Service Layer#
Event Bookmark Service:
// src/services/event_bookmarks.rs
use anyhow::Result;
use chrono::{DateTime, Utc};
pub struct EventBookmarkService {
storage: Arc<StoragePool>,
atproto_client: Arc<AtprotoClient>,
}
impl EventBookmarkService {
pub async fn bookmark_event(
&self,
did: &str,
event_aturi: &str,
tags: Vec<String>,
) -> Result<String, BookmarkError> {
// 1. Create bookmark record in ATproto (source of truth)
let bookmark = Bookmark {
subject: event_aturi.to_string(), // Direct event AT-URI
created_at: Utc::now(),
tags: tags.clone(),
};
let bookmark_aturi = self.atproto_client.create_record(
did,
BOOKMARK_NSID,
&bookmark,
).await?;
// 2. Cache locally for performance
storage::event_bookmarks::insert(
&self.storage,
did,
&bookmark_aturi,
event_aturi,
&tags,
).await?;
Ok(bookmark_aturi)
}
pub async fn get_bookmarked_events(
&self,
did: &str,
tags: Option<Vec<String>>,
date_range: Option<(DateTime<Utc>, DateTime<Utc>)>,
limit: Option<i32>,
offset: Option<i32>,
force_sync: bool,
) -> Result<PaginatedBookmarkedEvents, BookmarkError> {
if force_sync || self.needs_sync(did).await? {
self.sync_bookmarks_from_atproto(did).await?;
}
// Fetch from local cache with filters and pagination
let (bookmarks, total_count) = storage::event_bookmarks::get_by_filters_paginated(
&self.storage,
did,
tags,
date_range,
limit.unwrap_or(20), // Default page size
offset.unwrap_or(0),
).await?;
// Enrich with event details
let mut enriched = Vec::new();
for bookmark in bookmarks {
if let Ok(event) = storage::event::get_by_aturi(&self.storage, &bookmark.event_aturi).await {
enriched.push(BookmarkedEvent {
bookmark,
event,
});
}
}
Ok(PaginatedBookmarkedEvents {
events: enriched,
total_count,
has_more: offset.unwrap_or(0) + limit.unwrap_or(20) < total_count as i32,
})
}
pub async fn sync_bookmarks_from_atproto(&self, did: &str) -> Result<(), BookmarkError> {
// Fetch all bookmarks from ATproto (source of truth)
let mut cursor = None;
let mut all_bookmarks = Vec::new();
loop {
let params = GetActorBookmarksParams {
tags: None, // Get all bookmarks
limit: Some(100),
cursor: cursor.clone(),
};
let response = self.atproto_client.get_actor_bookmarks(did, ¶ms).await?;
for bookmark_record in response.bookmarks {
// Only process event bookmarks
if bookmark_record.value.subject.contains("community.lexicon.calendar.event") {
all_bookmarks.push(bookmark_record);
}
}
cursor = response.cursor;
if cursor.is_none() {
break;
}
}
// Update local cache
storage::event_bookmarks::sync_from_atproto(&self.storage, did, &all_bookmarks).await?;
Ok(())
}
pub async fn remove_bookmark(
&self,
did: &str,
bookmark_aturi: &str,
) -> Result<(), BookmarkError> {
// 1. Delete from ATproto (source of truth)
self.atproto_client.delete_record(did, bookmark_aturi).await?;
// 2. Remove from local cache
storage::event_bookmarks::delete_by_bookmark_aturi(&self.storage, did, bookmark_aturi).await?;
Ok(())
}
pub async fn get_available_tags(&self, did: &str) -> Result<Vec<String>, BookmarkError> {
// Get tags from ATproto to ensure accuracy
let params = GetActorBookmarksParams {
tags: None,
limit: Some(1000), // Get a large sample
cursor: None,
};
let response = self.atproto_client.get_actor_bookmarks(did, ¶ms).await?;
let mut tags = std::collections::HashSet::new();
for bookmark_record in response.bookmarks {
if bookmark_record.value.subject.contains("community.lexicon.calendar.event") {
for tag in bookmark_record.value.tags {
tags.insert(tag);
}
}
}
Ok(tags.into_iter().collect())
}
pub async fn export_bookmarks_ical(
&self,
did: &str,
tags: Option<Vec<String>>,
date_range: Option<(DateTime<Utc>, DateTime<Utc>)>,
language: &str,
) -> Result<String, BookmarkError> {
// Get bookmarked events (force sync to ensure latest data)
let paginated_events = self.get_bookmarked_events(
did,
tags.clone(),
date_range,
None, // No limit for export
None, // No offset for export
true, // Force sync for export
).await?;
// Create calendar using existing iCal infrastructure
let calendar_name = LOCALES.lookup(language, "ical-bookmark-calendar-name");
let mut calendar = ICalendar::new("2.0", &calendar_name);
// Add each bookmarked event to the calendar
for bookmarked_event in paginated_events.events {
let event = &bookmarked_event.event;
let bookmark = &bookmarked_event.bookmark;
// Create iCal event using existing logic from handle_ical_event.rs
let mut ical_event = IcsEvent::new(
format!("bookmark-{}@smokesignal.events", bookmark.id),
format_timestamp(&Utc::now()),
);
// Set event properties using existing infrastructure
let event_title = if event.name.trim().is_empty() {
LOCALES.lookup(language, "ical-untitled-event")
} else {
event.name.clone()
};
ical_event.push(Summary::new(event_title));
// Add bookmark tags to description
let mut description = event.description_short.clone().unwrap_or_default();
if !bookmark.tags.is_empty() {
let tags_text = LOCALES.lookup_with_args(language, "ical-bookmark-tags", &[
("tags", bookmark.tags.join(", ").into())
]);
description = format!("{}\n\n{}", description, tags_text);
}
ical_event.push(Description::new(description));
// Set start time if available
if let Some(starts_at_str) = &event.starts_at_machine {
if let Ok(starts_at) = DateTime::parse_from_rfc3339(starts_at_str) {
ical_event.push(DtStart::new(format_timestamp(&starts_at.with_timezone(&Utc))));
}
}
// Set location
let event_location = event.location.clone().unwrap_or_else(|| {
match event.mode.as_deref() {
Some("virtual") => LOCALES.lookup(language, "ical-online-event"),
_ => LOCALES.lookup(language, "ical-location-tba"),
}
});
ical_event.push(Location::new(event_location));
// Set organizer using existing AT Protocol format
if let Some(organizer_name) = &event.organizer_display_name {
let organizer_field = format!("CN={}:at://unknown", organizer_name);
ical_event.push(Organizer::new(organizer_field));
}
calendar.add_event(ical_event);
}
Ok(calendar.to_string())
}
async fn needs_sync(&self, did: &str) -> Result<bool, BookmarkError> {
// Check if local cache is stale (e.g., older than 5 minutes)
storage::event_bookmarks::is_cache_stale(&self.storage, did, Duration::minutes(5)).await
}
}
#[derive(Debug, Clone)]
pub struct BookmarkedEvent {
pub bookmark: EventBookmark,
pub event: HydratedEvent,
}
#[derive(Debug, Clone)]
pub struct PaginatedBookmarkedEvents {
pub events: Vec<BookmarkedEvent>,
pub total_count: i64,
pub has_more: bool,
}
#[derive(Debug, Clone)]
pub struct EventBookmark {
pub id: i32,
pub did: String,
pub bookmark_aturi: String,
pub event_aturi: String,
pub tags: Vec<String>,
pub created_at: DateTime<Utc>,
pub synced_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub struct HydratedEvent {
pub aturi: String,
pub name: String,
pub site_url: String,
pub starts_at_human: Option<String>,
pub starts_at_machine: Option<String>,
pub ends_at_human: Option<String>,
pub status: Option<String>, // planned, scheduled, cancelled, etc.
pub mode: Option<String>, // inperson, virtual, hybrid
pub collection: String, // lexicon type
pub organizer_display_name: Option<String>,
pub organizer_did: Option<String>,
pub description_short: Option<String>,
pub location: Option<String>,
pub count_going: i64,
pub count_interested: i64,
pub count_not_going: i64,
}
4. Storage Layer Implementation#
Event Bookmarks Storage:
// src/storage/event_bookmarks.rs
use super::{StoragePool, StorageError};
use chrono::{DateTime, Utc, Duration};
pub async fn insert(
pool: &StoragePool,
did: &str,
bookmark_aturi: &str,
event_aturi: &str,
tags: &[String],
) -> Result<(), StorageError> {
sqlx::query!(
r#"
INSERT INTO event_bookmarks (did, bookmark_aturi, event_aturi, tags)
VALUES ($1, $2, $3, $4)
ON CONFLICT (did, bookmark_aturi)
DO UPDATE SET
tags = EXCLUDED.tags,
synced_at = NOW()
"#,
did,
bookmark_aturi,
event_aturi,
tags
)
.execute(pool)
.await?;
Ok(())
}
pub async fn sync_from_atproto(
pool: &StoragePool,
did: &str,
bookmark_records: &[BookmarkRecord],
) -> Result<(), StorageError> {
let mut tx = pool.begin().await?;
// Clear existing bookmarks for this user
sqlx::query!(
"DELETE FROM event_bookmarks WHERE did = $1",
did
)
.execute(&mut *tx)
.await?;
// Insert fresh data from ATproto
for record in bookmark_records {
sqlx::query!(
r#"
INSERT INTO event_bookmarks (did, bookmark_aturi, event_aturi, tags, created_at, synced_at)
VALUES ($1, $2, $3, $4, $5, NOW())
"#,
did,
record.uri,
record.value.subject,
&record.value.tags,
record.value.created_at
)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(())
}
pub async fn get_by_filters_paginated(
pool: &StoragePool,
did: &str,
tags: Option<Vec<String>>,
date_range: Option<(DateTime<Utc>, DateTime<Utc>)>,
limit: i32,
offset: i32,
) -> Result<(Vec<EventBookmark>, i64), StorageError> {
// First get total count
let mut count_query = QueryBuilder::new(
r#"
SELECT COUNT(*)
FROM event_bookmarks eb
JOIN events e ON eb.event_aturi = e.aturi
WHERE eb.did =
"#
);
count_query.push_bind(did);
// Add same filters as main query
if let Some(filter_tags) = &tags {
count_query.push(" AND eb.tags && ");
count_query.push_bind(filter_tags.clone());
}
if let Some((start, end)) = date_range {
count_query.push(r#" AND (e.record->>'startsAt')::timestamp >= "#);
count_query.push_bind(start);
count_query.push(" AND (e.record->>'startsAt')::timestamp <= ");
count_query.push_bind(end);
}
let total_count: i64 = count_query
.build_query_scalar()
.fetch_one(pool)
.await?;
// Then get paginated results
let mut query = QueryBuilder::new(
r#"
SELECT eb.*, e.record, e.name as event_name
FROM event_bookmarks eb
JOIN events e ON eb.event_aturi = e.aturi
WHERE eb.did =
"#
);
query.push_bind(did);
// Add tag filtering
if let Some(filter_tags) = tags {
query.push(" AND eb.tags && ");
query.push_bind(filter_tags);
}
// Add date range filtering
if let Some((start, end)) = date_range {
query.push(r#" AND (e.record->>'startsAt')::timestamp >= "#);
query.push_bind(start);
query.push(" AND (e.record->>'startsAt')::timestamp <= ");
query.push_bind(end);
}
query.push(" ORDER BY (e.record->>'startsAt')::timestamp ASC");
query.push(" LIMIT ");
query.push_bind(limit);
query.push(" OFFSET ");
query.push_bind(offset);
let bookmarks = query
.build_query_as::<EventBookmark>()
.fetch_all(pool)
.await?;
Ok((bookmarks, total_count))
}
pub async fn get_by_filters(
pool: &StoragePool,
did: &str,
tags: Option<Vec<String>>,
date_range: Option<(DateTime<Utc>, DateTime<Utc>)>,
) -> Result<Vec<EventBookmark>, StorageError> {
let (bookmarks, _) = get_by_filters_paginated(pool, did, tags, date_range, 1000, 0).await?;
Ok(bookmarks)
}
pub async fn delete_by_bookmark_aturi(
pool: &StoragePool,
did: &str,
bookmark_aturi: &str,
) -> Result<(), StorageError> {
sqlx::query!(
"DELETE FROM event_bookmarks WHERE did = $1 AND bookmark_aturi = $2",
did,
bookmark_aturi
)
.execute(pool)
.await?;
Ok(())
}
pub async fn is_cache_stale(
pool: &StoragePool,
did: &str,
max_age: Duration,
) -> Result<bool, StorageError> {
let cutoff = Utc::now() - max_age;
let result = sqlx::query!(
r#"
SELECT COUNT(*) as count
FROM event_bookmarks
WHERE did = $1 AND synced_at > $2
"#,
did,
cutoff
)
.fetch_one(pool)
.await?;
Ok(result.count.unwrap_or(0) == 0)
}
Custom Calendars Storage:
// src/storage/bookmark_calendars.rs
use super::{StoragePool, StorageError};
use chrono::{DateTime, Utc};
pub async fn insert(
pool: &StoragePool,
calendar: &BookmarkCalendar,
) -> Result<BookmarkCalendar, StorageError> {
let result = sqlx::query!(
r#"
INSERT INTO bookmark_calendars (did, calendar_id, name, description, tags, tag_operator, is_public, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
RETURNING id, did, calendar_id, name, description, tags, tag_operator, is_public, created_at, updated_at
"#,
calendar.did,
calendar.calendar_id,
calendar.name,
calendar.description,
&calendar.tags,
calendar.tag_operator,
calendar.is_public
)
.fetch_one(pool)
.await?;
Ok(BookmarkCalendar {
id: result.id,
did: result.did,
calendar_id: result.calendar_id,
name: result.name,
description: result.description,
tags: result.tags,
tag_operator: result.tag_operator,
is_public: result.is_public,
created_at: result.created_at,
updated_at: result.updated_at,
})
}
pub async fn get_by_calendar_id(
pool: &StoragePool,
did: &str,
calendar_id: &str,
) -> Result<BookmarkCalendar, StorageError> {
let result = sqlx::query!(
r#"
SELECT * FROM bookmark_calendars WHERE did = $1 AND calendar_id = $2
"#,
did,
calendar_id
)
.fetch_one(pool)
.await?;
Ok(BookmarkCalendar {
id: result.id,
did: result.did,
calendar_id: result.calendar_id,
name: result.name,
description: result.description,
tags: result.tags,
tag_operator: result.tag_operator,
is_public: result.is_public,
created_at: result.created_at,
updated_at: result.updated_at,
})
}
pub async fn get_by_user(
pool: &StoragePool,
did: &str,
include_private: bool,
) -> Result<Vec<BookmarkCalendar>, StorageError> {
let mut query = r#"SELECT * FROM bookmark_calendars WHERE did = $1"#.to_string();
if !include_private {
query.push_str(" AND is_public = true");
}
let results = sqlx::query_as::<_, BookmarkCalendar>(&query)
.bind(did)
.fetch_all(pool)
.await?;
Ok(results)
}
pub async fn get_public_paginated(
pool: &StoragePool,
limit: i32,
offset: i32,
) -> Result<(Vec<BookmarkCalendar>, i64), StorageError> {
let query = r#"
SELECT * FROM bookmark_calendars
WHERE is_public = true
ORDER BY created_at DESC
LIMIT $1 OFFSET $2
"#;
let results = sqlx::query_as::<_, BookmarkCalendar>(query)
.bind(limit)
.bind(offset)
.fetch_all(pool)
.await?;
let count_query = r#"SELECT COUNT(*) FROM bookmark_calendars WHERE is_public = true"#;
let total_count: i64 = sqlx::query_scalar(count_query)
.fetch_one(pool)
.await?;
Ok((results, total_count))
}
pub async fn update(
pool: &StoragePool,
calendar: &BookmarkCalendar,
) -> Result<(), StorageError> {
sqlx::query!(
r#"
UPDATE bookmark_calendars
SET name = $1, description = $2, tags = $3, tag_operator = $4, is_public = $5, updated_at = NOW()
WHERE did = $6 AND calendar_id = $7
"#,
calendar.name,
calendar.description,
&calendar.tags,
calendar.tag_operator,
calendar.is_public,
calendar.did,
calendar.calendar_id
)
.execute(pool)
.await?;
Ok(())
}
pub async fn delete(
pool: &StoragePool,
did: &str,
calendar_id: &str,
) -> Result<(), StorageError> {
sqlx::query!(
r#"
DELETE FROM bookmark_calendars WHERE did = $1 AND calendar_id = $2
"#,
did,
calendar_id
)
.execute(pool)
.await?;
Ok(())
}
pub async fn update_event_count(
pool: &StoragePool,
calendar_id: i32,
increment: i32,
) -> Result<(), StorageError> {
sqlx::query!(
r#"
UPDATE bookmark_calendar_stats
SET event_count = event_count + $1, last_updated = NOW()
WHERE calendar_id = $2
"#,
increment,
calendar_id
)
.execute(pool)
.await?;
Ok(())
}
pub async fn get_calendar_stats(
pool: &StoragePool,
calendar_id: i32,
) -> Result<BookmarkCalendarStats, StorageError> {
let result = sqlx::query!(
r#"
SELECT * FROM bookmark_calendar_stats WHERE calendar_id = $1
"#,
calendar_id
)
.fetch_one(pool)
.await?;
Ok(BookmarkCalendarStats {
calendar_id: result.calendar_id,
event_count: result.event_count,
follower_count: result.follower_count,
last_updated: result.last_updated,
})
}
5. HTTP Handlers#
Bookmark Management Handlers:
// src/http/handle_bookmark_events.rs
use axum::{extract::{Query, State}, response::Html, Json};
use serde::Deserialize;
#[derive(Deserialize)]
pub struct BookmarkEventParams {
event_aturi: String,
tags: Option<String>, // Comma-separated tags
}
pub async fn handle_bookmark_event(
State(ctx): State<Arc<AppContext>>,
Query(params): Query<BookmarkEventParams>,
) -> Result<Html<String>, HttpError> {
let session = get_session_or_redirect(&ctx, &headers).await?;
let tags = params.tags
.map(|t| t.split(',').map(|s| s.trim().to_string()).collect())
.unwrap_or_default();
let bookmark_aturi = ctx.bookmark_service.bookmark_event(
&session.did,
¶ms.event_aturi,
tags,
).await?;
// Return HTMX fragment showing success
let renderer = create_renderer!(ctx, session.language, true);
let html = renderer.render("bookmark_success", &json!({ // Will resolve to bookmark_success.{locale}.partial.html
"bookmarked": true,
"bookmark_aturi": bookmark_aturi
}))?;
Ok(Html(html))
}
#[derive(Deserialize)]
pub struct CalendarViewParams {
tags: Option<String>,
view: Option<String>, // "timeline" or "calendar"
start_date: Option<String>,
end_date: Option<String>,
}
pub async fn handle_bookmark_calendar(
State(ctx): State<Arc<AppContext>>,
Query(params): Query<CalendarViewParams>,
) -> Result<Html<String>, HttpError> {
let session = get_session_or_redirect(&ctx, &headers).await?;
let tags = params.tags
.map(|t| t.split(',').map(|s| s.trim().to_string()).collect());
let date_range = parse_date_range(params.start_date, params.end_date)?;
// Force sync on explicit calendar view to ensure fresh data
let force_sync = !is_htmx_request(&headers);
let bookmarked_events = ctx.bookmark_service.get_bookmarked_events(
&session.did,
tags.clone(),
date_range,
force_sync,
).await?;
// Get available tags from ATproto for filtering UI
let available_tags = ctx.bookmark_service.get_available_tags(&session.did).await?;
let renderer = create_renderer!(ctx, session.language, is_htmx_request);
let template = match params.view.as_deref() {
Some("calendar") => "bookmark_calendar_view",
_ => "bookmark_timeline", // Will resolve to bookmark_timeline.{locale}.html
};
let html = renderer.render(template, &json!({
"events": bookmarked_events,
"available_tags": available_tags,
"current_tags": params.tags,
"view_mode": params.view.unwrap_or_else(|| "timeline".to_string()),
}))?;
Ok(Html(html))
}
pub async fn handle_remove_bookmark(
State(ctx): State<Arc<AppContext>>,
Path(bookmark_aturi): Path<String>,
) -> Result<Html<String>, HttpError> {
let session = get_session_or_redirect(&ctx, &headers).await?;
ctx.bookmark_service.remove_bookmark(
&session.did,
&bookmark_aturi,
).await?;
// Return empty response for HTMX to remove the element
Ok(Html(String::new()))
}
pub async fn handle_bookmark_calendar_ical(
State(ctx): State<Arc<AppContext>>,
Query(params): Query<CalendarViewParams>,
) -> Result<impl IntoResponse, HttpError> {
let session = get_session_or_redirect(&ctx, &headers).await?;
let tags = params.tags
.map(|t| t.split(',').map(|s| s.trim().to_string()).collect());
let date_range = parse_date_range(params.start_date, params.end_date)?;
// Export bookmarks as iCal
let ical_content = ctx.bookmark_service.export_bookmarks_ical(
&session.did,
tags.clone(),
date_range,
&session.language,
).await?;
// Generate filename based on tags or use default
let filename = if let Some(tag_list) = &tags {
format!("bookmarks-{}.ics", tag_list.join("-"))
} else {
"bookmarks.ics".to_string()
};
// Create response with appropriate headers (reusing pattern from handle_ical_event.rs)
let mut headers = HeaderMap::new();
headers.insert(
header::CONTENT_TYPE,
"text/calendar; charset=utf-8".parse().unwrap(),
);
headers.insert(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{}\"", filename)
.parse()
.unwrap(),
);
Ok((StatusCode::OK, headers, ical_content))
}
Custom Calendar Handlers:
// src/http/handle_bookmark_calendars.rs
use axum::{extract::{Path, State}, response::Html, Json};
use serde::Deserialize;
#[derive(Deserialize)]
pub struct CreateBookmarkCalendarParams {
name: String,
description: Option<String>,
tags: Vec<String>,
tag_operator: Option<String>, // "AND" or "OR", defaults to "OR"
is_public: Option<bool>,
}
pub async fn handle_create_bookmark_calendar(
State(ctx): State<Arc<AppContext>>,
Json(payload): Json<CreateBookmarkCalendarParams>,
) -> Result<Html<String>, HttpError> {
let session = get_session_or_redirect(&ctx, &headers).await?;
let calendar = ctx.bookmark_calendar_service.create_calendar(
&session.did,
&payload.name,
payload.description.as_deref(),
&payload.tags,
&payload.tag_operator.unwrap_or_else(|| "OR".to_string()),
payload.is_public.unwrap_or(false),
).await?;
// Return HTMX fragment to update calendars container
let renderer = create_renderer!(ctx, session.language, true);
let html = renderer.render("bookmark_calendar_item", &json!({ // Will resolve to bookmark_calendar_item.{locale}.partial.html
"calendar": calendar,
}))?;
Ok(Html(html))
}
pub async fn handle_view_bookmark_calendar(
State(ctx): State<Arc<AppContext>>,
Path(calendar_id): Path<String>,
) -> Result<Html<String>, HttpError> {
let session = get_session_or_redirect(&ctx, &headers).await?;
let calendar = ctx.bookmark_calendar_service.get_by_calendar_id(&session.did, &calendar_id).await?;
// Fetch events in the calendar
let paginated_events = ctx.bookmark_calendar_service.get_calendar_events(
&session.did,
&calendar_id,
Some(20),
Some(0),
).await?;
let renderer = create_renderer!(ctx, session.language, is_htmx_request);
let html = renderer.render("bookmark_calendar_view", &json!({ // Will resolve to bookmark_calendar_view.{locale}.html
"calendar": calendar,
"events": paginated_events.events,
}))?;
Ok(Html(html))
}
pub async fn handle_delete_bookmark_calendar(
State(ctx): State<Arc<AppContext>>,
Path(calendar_id): Path<String>,
) -> Result<Html<String>, HttpError> {
let session = get_session_or_redirect(&ctx, &headers).await?;
ctx.bookmark_calendar_service.delete(
&session.did,
&calendar_id,
).await?;
// Return empty response for HTMX to remove the element
Ok(Html(String::new()))
}
pub async fn handle_add_event_to_calendar(
State(ctx): State<Arc<AppContext>>,
Path(calendar_id): Path<String>,
Json(payload): Json<AddEventToCalendarParams>,
) -> Result<Html<String>, HttpError> {
let session = get_session_or_redirect(&ctx, &headers).await?;
let bookmark_aturi = ctx.bookmark_calendar_service.add_event_to_calendar(
&session.did,
&calendar_id,
&payload.event_aturi,
).await?;
// Return HTMX fragment to update event count in calendar
let renderer = create_renderer!(ctx, session.language, true);
let html = renderer.render("bookmark_calendar_event_count", &json!({ // Will resolve to bookmark_calendar_event_count.{locale}.partial.html
"calendar_id": calendar_id,
}))?;
Ok(Html(html))
}
pub async fn handle_export_calendar_ical(
State(ctx): State<Arc<AppContext>>,
Path(calendar_id): Path<String>,
) -> Result<impl IntoResponse, HttpError> {
let session = get_session_or_redirect(&ctx, &headers).await?;
// Get the calendar to export
let calendar = ctx.bookmark_calendar_service.get_by_calendar_id(&session.did, &calendar_id).await?;
// Export bookmarks as iCal
let ical_content = ctx.bookmark_service.export_bookmarks_ical(
&session.did,
Some(calendar.tags),
None, // No date range
&session.language,
).await?;
// Generate filename based on calendar name
let filename = format!("{}.ics", calendar.name.replace(" ", "-"));
// Create response with appropriate headers
let mut headers = HeaderMap::new();
headers.insert(
header::CONTENT_TYPE,
"text/calendar; charset=utf-8".parse().unwrap(),
);
headers.insert(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{}\"", filename)
.parse()
.unwrap(),
);
Ok((StatusCode::OK, headers, ical_content))
}
6. Frontend Templates#
Timeline View Template:
<!-- templates/bookmark_timeline.en-us.html (main page with base layout) -->
<!-- Also need: bookmark_timeline.fr-ca.html, bookmark_timeline.en-us.bare.html, bookmark_timeline.fr-ca.bare.html -->
<div class="bookmark-calendar-container">
<div class="columns">
<!-- Left: Mini Calendar -->
<div class="column is-one-quarter">
<div class="calendar-widget">
<div class="calendar-header">
<h3 class="title is-5">{{ t("calendar-navigation") }}</h3>
</div>
<div class="calendar-grid" id="mini-calendar">
{% include "mini_calendar.html" %}
</div>
<!-- Tag Filter -->
<div class="tag-filter mt-4">
<h4 class="subtitle is-6">{{ t("filter-by-tags") }}</h4>
<div class="field is-grouped is-grouped-multiline">
{% for tag in available_tags %}
<div class="control">
<label class="checkbox">
<input type="checkbox"
name="tags"
value="{{ tag }}"
{% if tag in current_tags %}checked{% endif %}
hx-get="/bookmark-calendar"
hx-trigger="change"
hx-target="#timeline-content"
hx-include="[name='tags']:checked">
{{ tag }}
</label>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
<!-- Right: Timeline -->
<div class="column">
<div class="timeline-container">
<div class="level">
<div class="level-left">
<h2 class="title is-4">{{ t("bookmarked-events") }}</h2>
</div>
<div class="level-right">
<div class="field has-addons">
<div class="control">
<button class="button is-primary is-small"
hx-get="/bookmark-calendar?view=timeline"
hx-target="#main-content">
{{ t("timeline-view") }}
</button>
</div>
<div class="control">
<button class="button is-light is-small"
hx-get="/bookmark-calendar?view=calendar"
hx-target="#main-content">
{{ t("calendar-view") }}
</button>
</div>
</div>
</div>
</div>
<div id="timeline-content">
{% include "bookmark_timeline." + current_locale + ".partial.html" %}
</div>
</div>
</div>
</div>
</div>
Timeline Events Fragment:
<!-- templates/bookmark_timeline.en-us.partial.html (HTMX partial for timeline content) -->
<!-- Also need: bookmark_timeline.fr-ca.partial.html -->
<div class="timeline">
{% for bookmarked_event in events %}
<div class="timeline-item">
<div class="timeline-marker is-icon">
<i class="fas fa-bookmark" style="color: #ff6b6b;"></i>
</div>
<div class="timeline-content">
<div class="event-card">
<!-- Bookmark Tags Header -->
<div class="bookmark-tags-header">
{% for tag in bookmarked_event.bookmark.tags %}
<span class="tag is-primary is-small">{{ tag }}</span>
{% endfor %}
<div class="bookmark-actions">
<button class="button is-danger is-small"
hx-delete="/bookmarks/{{ bookmarked_event.bookmark.bookmark_aturi | urlencode }}"
hx-target="closest .timeline-item"
hx-swap="outerHTML"
hx-confirm="{{ t('confirm-remove-bookmark') }}"
title="{{ t('remove-bookmark') }}">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
<div class="event-card-content">
<!-- Time and Date -->
{% if bookmarked_event.event.starts_at_human %}
<div class="event-time">
<i class="fas fa-clock"></i>
<time class="dt-start" {% if bookmarked_event.event.starts_at_machine %}datetime="{{ bookmarked_event.event.starts_at_machine }}"{% endif %}>
{{ bookmarked_event.event.starts_at_human }}
</time>
</div>
{% endif %}
<!-- Event Title -->
<h3 class="event-title">
<a href="{{ base }}{{ bookmarked_event.event.site_url }}" hx-boost="true">
{% autoescape false %}{{ bookmarked_event.event.name }}{% endautoescape %}
</a>
</h3>
<!-- Event Tags (Status, Mode, etc.) -->
<div class="event-tags">
<!-- Status Tag -->
{% if bookmarked_event.event.status == "planned" %}
<span class="event-tag tag-planned" title="{{ t('status-planned') }}">
<i class="fas fa-calendar-days"></i> {{ t("status-planned") }}
</span>
{% elif bookmarked_event.event.status == "scheduled" %}
<span class="event-tag tag-scheduled" title="{{ t('status-scheduled') }}">
<i class="fas fa-calendar-check"></i> {{ t("status-scheduled") }}
</span>
{% elif bookmarked_event.event.status == "rescheduled" %}
<span class="event-tag tag-rescheduled" title="{{ t('status-rescheduled') }}">
<i class="fas fa-calendar-plus"></i> {{ t("status-rescheduled") }}
</span>
{% elif bookmarked_event.event.status == "cancelled" %}
<span class="event-tag tag-cancelled" title="{{ t('status-cancelled') }}">
<i class="fas fa-calendar-xmark"></i> {{ t("status-cancelled") }}
</span>
{% elif bookmarked_event.event.status == "postponed" %}
<span class="event-tag tag-postponed" title="{{ t('status-postponed') }}">
<i class="fas fa-calendar-minus"></i> {{ t("status-postponed") }}
</span>
{% endif %}
<!-- Mode Tag -->
{% if bookmarked_event.event.mode == "inperson" %}
<span class="event-tag tag-inperson">
<i class="fas fa-users"></i> {{ t("mode-in-person") }}
</span>
{% elif bookmarked_event.event.mode == "virtual" %}
<span class="event-tag tag-virtual">
<i class="fas fa-video"></i> {{ t("mode-virtual") }}
</span>
{% elif bookmarked_event.event.mode == "hybrid" %}
<span class="event-tag tag-hybrid">
<i class="fas fa-globe"></i> {{ t("mode-hybrid") }}
</span>
{% endif %}
<!-- Legacy Tag -->
{% if bookmarked_event.event.collection != "community.lexicon.calendar.event" %}
<span class="event-tag tag-legacy">{{ t("status-legacy") }}</span>
{% endif %}
</div>
<!-- Organizer Info -->
{% if bookmarked_event.event.organizer_display_name %}
<div class="event-organizer">
<div class="organizer-avatar">
{{ bookmarked_event.event.organizer_display_name|first|upper }}
</div>
<span class="organizer-name">
<a href="{{ base }}/{{ bookmarked_event.event.organizer_did }}" hx-boost="true" style="color: inherit; text-decoration: none;">
{{ bookmarked_event.event.organizer_display_name }}
</a>
</span>
</div>
{% endif %}
<!-- Event Description Preview -->
{% if bookmarked_event.event.description_short %}
<div class="event-description">
{% autoescape false %}{{ bookmarked_event.event.description_short }}{% endautoescape %}
</div>
{% endif %}
<!-- Location -->
{% if bookmarked_event.event.location %}
<div class="event-location" style="color: #aaa; font-size: 0.875rem; margin-bottom: 1rem;">
<i class="fas fa-map-marker-alt" style="color: #7dd87f; margin-right: 0.5rem;"></i>
{{ bookmarked_event.event.location }}
</div>
{% endif %}
<!-- End Time -->
{% if bookmarked_event.event.ends_at_human %}
<div class="event-end-time" style="color: #888; font-size: 0.75rem; margin-bottom: 1rem;">
<i class="fas fa-clock" style="margin-right: 0.25rem;"></i>
{{ t("ends-at", time=bookmarked_event.event.ends_at_human) }}
</div>
{% endif %}
<!-- Event Statistics -->
<div class="event-stats">
<div class="stat" title="{{ t('event-count-going', count=bookmarked_event.event.count_going) }}">
<i class="fas fa-star"></i>
<span>{{ bookmarked_event.event.count_going }}</span>
</div>
<div class="stat" title="{{ t('event-count-interested', count=bookmarked_event.event.count_interested) }}">
<i class="fas fa-eye"></i>
<span>{{ bookmarked_event.event.count_interested }}</span>
</div>
<div class="stat" title="{{ t('event-count-not-going', count=bookmarked_event.event.count_not_going | default(0)) }}">
<i class="fas fa-ban"></i>
<span>{{ bookmarked_event.event.count_not_going | default(0) }}</span>
</div>
</div>
<!-- Action Buttons -->
<div class="event-actions">
<a href="{{ base }}{{ bookmarked_event.event.site_url }}" class="action-btn" hx-boost="true">
<i class="fas fa-eye"></i>
{{ t("view-event") }}
</a>
</div>
<!-- Bookmark Metadata -->
<div class="bookmark-metadata">
<small class="has-text-grey">
<i class="fas fa-bookmark"></i>
{{ t("bookmarked-on", date=bookmarked_event.bookmark.created_at | date_format(locale)) }}
</small>
</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
Mini Calendar Component:
<!-- templates/mini_calendar.html (locale-agnostic calendar widget) -->
<div class="calendar-mini">
<div class="calendar-nav">
<button class="button is-small is-text"
hx-get="/bookmark-calendar/calendar-nav?month=-1"
hx-target="#mini-calendar">
<i class="fa fa-chevron-left"></i>
</button>
<span class="has-text-weight-semibold">{{ current_month | month_name(locale) }} {{ current_year }}</span>
<button class="button is-small is-text"
hx-get="/bookmark-calendar/calendar-nav?month=1"
hx-target="#mini-calendar">
<i class="fa fa-chevron-right"></i>
</button>
</div>
<table class="calendar-grid">
<thead>
<tr>
{% for day in weekdays %}
<th class="has-text-centered">{{ day | truncate(1) }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for week in calendar_weeks %}
<tr>
{% for day in week %}
<td class="calendar-day
{% if day.has_events %}has-events{% endif %}
{% if day.is_today %}is-today{% endif %}
{% if day.is_selected %}is-selected{% endif %}">
<button class="calendar-day-btn"
{% if day.has_events %}
hx-get="/bookmark-calendar?date={{ day.date | date_format('Y-m-d') }}"
hx-target="#timeline-content"
{% endif %}>
{{ day.number }}
{% if day.event_count > 0 %}
<span class="event-indicator">{{ day.event_count }}</span>
{% endif %}
</button>
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
Profile Lists Section:
<!-- templates/profile_bookmark_lists.en-us.incl.html (include fragment for profile pages) -->
<!-- Also need: profile_bookmark_lists.fr-ca.incl.html -->
<div class="profile-section">
<div class="section-header">
<h3 class="title is-4">
<i class="fas fa-bookmark"></i>
{{ t("bookmark-lists") }}
</h3>
{% if is_own_profile %}
<a href="/bookmark-lists/new" class="button is-primary is-small">
<i class="fas fa-plus"></i>
{{ t("create-list") }}
</a>
{% endif %}
</div>
{% if lists %}
<div class="bookmark-lists-grid">
{% for list_with_stats in lists %}
<div class="list-card">
<div class="list-card-header">
<h4 class="list-name">
<a href="/bookmark-lists/{{ list_with_stats.list.list_id }}">
{{ list_with_stats.list.name }}
</a>
</h4>
<div class="list-stats">
<span class="tag is-light">
{{ list_with_stats.event_count }} {{ t("events") }}
</span>
</div>
</div>
{% if list_with_stats.list.description %}
<p class="list-description">{{ list_with_stats.list.description }}</p>
{% endif %}
<div class="list-card-footer">
<small class="has-text-grey">
{{ t("created") }} {{ list_with_stats.list.created_at | date }}
</small>
<div class="list-actions">
<a href="/bookmark-lists/{{ list_with_stats.list.list_id }}.ics"
class="button is-small is-outlined"
title="{{ t('export-calendar') }}">
<i class="fas fa-calendar-alt"></i>
</a>
<button class="button is-small is-outlined share-list-btn"
data-list-url="{{ base_url }}/bookmark-lists/{{ list_with_stats.list.list_id }}"
title="{{ t('share-list') }}">
<i class="fas fa-share"></i>
</button>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<p class="has-text-grey">{{ t("no-public-lists") }}</p>
</div>
{% endif %}
</div>
Enhanced List Creation Modal:
<!-- templates/bookmark_list_modal.en-us.partial.html (HTMX modal partial) -->
<!-- Also need: bookmark_list_modal.fr-ca.partial.html -->
<div class="modal" id="create-list-modal">
<div class="modal-background"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">{{ t("create-bookmark-list") }}</p>
<button class="delete" onclick="closeListModal()"></button>
</header>
<section class="modal-card-body">
<form id="create-list-form">
<div class="field">
<label class="label">{{ t("list-name") }}</label>
<div class="control">
<input class="input" type="text" name="name"
placeholder="{{ t('list-name-placeholder') }}" required>
</div>
<p class="help">{{ t("list-name-help") }}</p>
</div>
<div class="field">
<label class="label">{{ t("list-tags") }}</label>
<div class="control">
<div class="tags-input-container">
<div id="selected-list-tags" class="tags-display"></div>
<input class="input" type="text" id="new-list-tag-input"
placeholder="{{ t('add-tag-placeholder') }}"
onkeypress="handleTagInput(event)">
</div>
</div>
<p class="help">{{ t("list-tags-help") }}</p>
</div>
<div class="field">
<label class="label">{{ t("list-description") }}</label>
<div class="control">
<textarea class="textarea" name="description"
placeholder="{{ t('list-description-placeholder') }}"></textarea>
</div>
</div>
<div class="field">
<div class="control">
<label class="checkbox">
<input type="checkbox" name="is_public">
{{ t("make-calendar-public") }}
</label>
</div>
<p class="help">{{ t("public-calendar-help") }}</p>
<div class="notification is-info is-light">
<p class="is-size-7">
<strong>{{ t("atproto-privacy-notice") }}</strong><br>
{{ t("atproto-privacy-explanation") }}
</p>
</div>
</div>
</form>
</section>
<footer class="modal-card-foot">
<button class="button is-primary" onclick="submitListForm()">
{{ t("create-list") }}
</button>
<button class="button" onclick="closeListModal()">
{{ t("cancel") }}
</button>
</footer>
</div>
</div>
Enhanced JavaScript for Tag Management:
// static/bookmark-calendars.js
let calendarTags = [];
function handleTagInput(event) {
if (event.key === 'Enter' || event.key === ',') {
event.preventDefault();
addCalendarTag();
}
}
function addCalendarTag() {
const input = document.getElementById('new-calendar-tag-input');
const tag = input.value.trim().toLowerCase();
if (tag && !calendarTags.includes(tag)) {
calendarTags.push(tag);
renderCalendarTags();
input.value = '';
}
}
function removeCalendarTag(tag) {
calendarTags = calendarTags.filter(t => t !== tag);
renderCalendarTags();
}
function renderCalendarTags() {
const container = document.getElementById('selected-calendar-tags');
container.innerHTML = calendarTags.map(tag => `
<span class="tag is-primary">
${tag}
<button class="delete is-small" onclick="removeCalendarTag('${tag}')"></button>
</span>
`).join('');
}
function submitCalendarForm() {
const form = document.getElementById('create-calendar-form');
const formData = new FormData(form);
const payload = {
name: formData.get('name'),
description: formData.get('description'),
tags: calendarTags,
tag_operator: formData.get('tag_operator') || 'OR',
is_public: formData.has('is_public')
};
fetch('/bookmark-calendars', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
.then(response => response.text())
.then(html => {
document.getElementById('calendars-container').insertAdjacentHTML('afterbegin', html);
closeCalendarModal();
showNotification('{{ t("calendar-created") }}', 'success');
})
.catch(error => {
showNotification('{{ t("error-creating-calendar") }}', 'error');
});
}
// Smart calendar suggestions based on existing tags
function suggestCalendarTags(eventTags) {
const suggestions = document.getElementById('tag-suggestions');
if (!suggestions) return;
// Show relevant existing tags as suggestions
const existingTags = [...new Set(eventTags)];
suggestions.innerHTML = existingTags.map(tag => `
<button class="button is-small is-light" onclick="addSuggestedTag('${tag}')">
${tag}
</button>
`).join('');
}
function addSuggestedTag(tag) {
if (!calendarTags.includes(tag)) {
calendarTags.push(tag);
renderCalendarTags();
}
}
Internationalization#
Translation Keys:
# i18n/en-us/bookmarks.ftl
bookmark-event = Bookmark Event
bookmark-calendar = Event Calendar
bookmarked-events = Bookmarked Events
timeline-view = Timeline
calendar-view = Calendar
filter-by-tags = Filter by Tags
calendar-navigation = Calendar
remove-bookmark = Remove Bookmark
confirm-remove-bookmark = Are you sure you want to remove this bookmark?
add-to-calendar = Add to Calendar
create-new-calendar = Create New Calendar
bookmark-success = Event bookmarked successfully!
no-bookmarked-events = No bookmarked events found
bookmark-tags = Tags (comma-separated)
bookmark-calendar-name = Calendar Name
bookmark-calendar-description = Description (optional)
make-calendar-public = Make this calendar public
bookmarked-on = Bookmarked on {$date}
ends-at = Ends at {$time}
# Enhanced calendar management
bookmark-calendars = Custom Calendars
create-calendar = Create Calendar
create-bookmark-calendar = Create Custom Calendar
create-new-calendar = Create New Calendar
calendar-name = Calendar Name
calendar-name-placeholder = e.g. Summer Festivals, Work Events
calendar-name-help = Choose a descriptive name for your calendar
calendar-tags = Tags
add-tag-placeholder = Add a tag and press Enter
calendar-tags-help = Add tags to organize your calendar
calendar-description-placeholder = What types of events will you collect?
make-calendar-public = Make this calendar public
public-calendar-help = Public calendars appear on your profile and can be discovered by others
atproto-privacy-notice = Privacy Notice
atproto-privacy-explanation = Your bookmarks and tags are always public on the ATproto network. This setting only controls whether this calendar appears on your smokesignal profile.
my-calendars = My Calendars
public-calendars = Public Calendars
no-public-calendars = No public calendars yet
export-calendar = Export Calendar
share-calendar = Share Calendar
events = events
created = Created on
calendar-updated = Calendar updated successfully
calendar-created = Calendar created successfully
event-added-to-calendar = Event added to calendar
# i18n/fr-ca/bookmarks.ftl
bookmark-event = Marquer l'événement
bookmark-calendar = Calendrier des événements
bookmarked-events = Événements marqués
timeline-view = Vue chronologique
calendar-view = Vue calendrier
filter-by-tags = Filtrer par étiquettes
calendar-navigation = Navigation
remove-bookmark = Retirer le marque-page
confirm-remove-bookmark = Êtes-vous sûr·e de vouloir retirer ce marque-page?
add-to-calendar = Ajouter au calendrier
create-new-calendar = Créer un nouveau calendrier
bookmark-success = Événement marqué avec succès!
no-bookmarked-events = Aucun événement marqué trouvé
bookmark-tags = Étiquettes (séparées par des virgules)
bookmark-calendar-name = Nom du calendrier
bookmark-calendar-description = Description (optionnelle)
make-calendar-public = Rendre ce calendrier public
bookmarked-on = Marqué le {$date}
ends-at = Se termine à {$time}
# Enhanced calendar management
bookmark-calendars = Calendriers personnalisés
create-calendar = Créer un calendrier
create-bookmark-calendar = Créer un calendrier personnalisé
create-new-calendar = Créer un nouveau calendrier
calendar-name = Nom du calendrier
calendar-name-placeholder = ex. Festivals d'été, Événements de travail
calendar-name-help = Choisissez un nom descriptif pour votre calendrier
calendar-tags = Étiquettes
add-tag-placeholder = Ajouter une étiquette et appuyer sur Entrée
calendar-tags-help = Ajoutez des étiquettes pour organiser votre calendrier
calendar-description-placeholder = Quels types d'événements allez-vous collecter ?
make-calendar-public = Rendre ce calendrier public
public-calendar-help = Les calendriers publics apparaissent sur votre profil et peuvent être découverts par d'autres
atproto-privacy-notice = Avis de confidentialité
atproto-privacy-explanation = Vos signets et étiquettes sont toujours publics sur le réseau ATproto. Ce paramètre contrôle seulement si ce calendrier apparaît sur votre profil smokesignal.
my-calendars = Mes calendriers
public-calendars = Calendriers publics
no-public-calendars = Pas encore de calendriers publics
export-calendar = Exporter le calendrier
share-calendar = Partager le calendrier
events = événements
created = Créé le
calendar-updated = Calendrier mis à jour avec succès
calendar-created = Calendrier créé avec succès
event-added-to-calendar = Événement ajouté au calendrier
# i18n/en-us/bookmarks.ftl (additions)
calendar-tags = Tags
calendar-tags-help = Add multiple tags to organize this calendar (press Enter or comma to add)
add-tag-placeholder = Add a tag...
tag-suggestions = Suggested tags
calendar-created-success = Calendar created with {$count} tags
calendar-updated-success = Calendar updated with {$count} tags
tag-match-all = Show events with ALL tags
tag-match-any = Show events with ANY tags
tag-strategy = Tag matching
tag-strategy-help = Choose how events are matched to this calendar
tag-operator = Tag operator
tag-operator-help = Combine tags with AND (all required) or OR (any required)
error-creating-calendar = Error creating calendar
error-max-tags = Maximum 10 tags allowed per calendar
error-empty-tag = Empty tags are not allowed
error-tag-too-long = Tag too long (maximum 50 characters)
# i18n/fr-ca/bookmarks.ftl (additions)
calendar-tags = Étiquettes
calendar-tags-help = Ajoutez plusieurs étiquettes pour organiser ce calendrier (appuyez sur Entrée ou virgule pour ajouter)
add-tag-placeholder = Ajouter une étiquette...
tag-suggestions = Étiquettes suggérées
calendar-created-success = Calendrier créé avec {$count} étiquettes
calendar-updated-success = Calendrier mis à jour avec {$count} étiquettes
tag-match-all = Afficher les événements avec TOUTES les étiquettes
tag-match-any = Afficher les événements avec N'IMPORTE QUELLE étiquette
tag-strategy = Correspondance d'étiquettes
tag-strategy-help = Choisissez comment les événements correspondent à ce calendrier
tag-operator = Opérateur d'étiquettes
tag-operator-help = Combinez les étiquettes avec ET (toutes requises) ou OU (n'importe laquelle requise)
error-creating-calendar = Erreur lors de la création du calendrier
error-max-tags = Maximum 10 étiquettes autorisées par calendrier
error-empty-tag = Les étiquettes vides ne sont pas autorisées
error-tag-too-long = Étiquette trop longue (maximum 50 caractères)
Required Template Files Summary#
Based on the i18n templating system used in smokesignal, the following template files need to be created for both en-us and fr-ca locales:
Core Calendar Templates (Timeline View)#
templates/bookmark_calendar_timeline.en-us.html # Calendar shown in timeline mode (main page)
templates/bookmark_calendar_timeline.fr-ca.html # French version
templates/bookmark_calendar_timeline.en-us.bare.html # Bare layout version
templates/bookmark_calendar_timeline.fr-ca.bare.html # French bare version
templates/bookmark_calendar_timeline.en-us.partial.html # HTMX partial updates
templates/bookmark_calendar_timeline.fr-ca.partial.html # French partial updates
Core Calendar Templates (Calendar Grid View)#
templates/bookmark_calendar_grid.en-us.html # Calendar shown in grid mode
templates/bookmark_calendar_grid.fr-ca.html # French version
templates/bookmark_calendar_grid.en-us.bare.html # Bare layout version
templates/bookmark_calendar_grid.fr-ca.bare.html # French bare version
templates/bookmark_calendar_grid.en-us.partial.html # HTMX partial updates
templates/bookmark_calendar_grid.fr-ca.partial.html # French partial updates
Bookmark Success Templates#
templates/bookmark_success.en-us.partial.html # Success feedback for HTMX
templates/bookmark_success.fr-ca.partial.html # French version
Calendar Management Templates#
templates/bookmark_calendar_item.en-us.partial.html # Calendar item for HTMX updates
templates/bookmark_calendar_item.fr-ca.partial.html # French version
templates/bookmark_calendar_event_count.en-us.partial.html # Event count updates
templates/bookmark_calendar_event_count.fr-ca.partial.html # French version
templates/bookmark_calendar_modal.en-us.partial.html # Calendar creation modal
templates/bookmark_calendar_modal.fr-ca.partial.html # French version
Profile Integration Templates#
templates/profile_bookmark_calendars.en-us.incl.html # Profile section include
templates/profile_bookmark_calendars.fr-ca.incl.html # French version
Locale-Agnostic Components#
templates/mini_calendar.html # Calendar widget (no i18n needed)
Template Usage Pattern#
The Rust route handlers will use the template naming convention:
"bookmark_calendar_timeline"resolves tobookmark_calendar_timeline.{locale}.html(calendar in timeline view)"bookmark_calendar_grid"resolves tobookmark_calendar_grid.{locale}.html(calendar in grid view)- Partials automatically get
.partial.htmlsuffix - Include fragments get
.incl.htmlsuffix
Key Point: All templates represent bookmark calendars - the suffix indicates the view mode, not different data types.
Integration with Existing System#
These templates will integrate with the existing template structure by:
- Extending
base.{locale}.htmlfor full pages - Extending
bare.{locale}.htmlfor modal/popup content - Using
{% include %}for shared components - Following existing CSS class patterns (Bulma framework)
- Using the
t()function for all user-facing text - Supporting HTMX attributes for dynamic updates
HTTP Routes#
The bookmark feature requires the following new routes to be added to src/http/server.rs:
// Add to the Router in build_router function:
.route("/bookmarks", get(handle_bookmark_calendar))
.route("/bookmarks", post(handle_bookmark_event))
.route("/bookmarks/:bookmark_aturi", delete(handle_remove_bookmark))
.route("/bookmarks/calendar-nav", get(handle_calendar_navigation))
.route("/bookmarks.ics", get(handle_bookmark_calendar_ical))
// Custom Calendar Management
.route("/bookmark-calendars", get(handle_bookmark_calendars_index))
.route("/bookmark-calendars", post(handle_create_bookmark_calendar))
.route("/bookmark-calendars/:calendar_id", get(handle_view_bookmark_calendar))
.route("/bookmark-calendars/:calendar_id", delete(handle_delete_bookmark_calendar))
.route("/bookmark-calendars/:calendar_id.ics", get(handle_export_calendar_ical))
.route("/bookmark-calendars/:calendar_id/events", post(handle_add_event_to_calendar))
.route("/bookmark-calendars/:calendar_id/events/:event_aturi", delete(handle_remove_event_from_calendar))
Navigation Integration#
The bookmarks feature should be integrated into the existing navigation system by adding a dropdown menu that clearly distinguishes between browsing events and managing personal calendars.
Update Navigation Templates#
Add to templates/nav.en-us.html and templates/nav.fr-ca.html:
<!-- Replace the existing events nav item with a dropdown -->
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link">
<span class="icon">
<i class="fas fa-calendar"></i>
</span>
{{ t("nav-events-calendars") }}
</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="/events" hx-boost="true">
<span class="icon">
<i class="fas fa-list"></i>
</span>
{{ t("nav-browse-events") }}
</a>
{% if current_handle %}
<hr class="navbar-divider">
<a class="navbar-item" href="/bookmark-calendars" hx-boost="true">
<span class="icon">
<i class="fas fa-bookmark"></i>
</span>
{{ t("nav-my-calendars") }}
</a>
<a class="navbar-item" href="/bookmarks" hx-boost="true">
<span class="icon">
<i class="fas fa-heart"></i>
</span>
{{ t("nav-all-bookmarks") }}
</a>
{% endif %}
</div>
</div>
i18n Updates for Navigation#
Add to i18n/en-us/common.ftl:
nav-events-calendars = Events & Calendars
nav-browse-events = Browse Events
nav-my-calendars = My Calendars
nav-all-bookmarks = All Bookmarks
Add to i18n/fr-ca/common.ftl:
nav-events-calendars = Événements et calendriers
nav-browse-events = Parcourir les événements
nav-my-calendars = Mes calendriers
nav-all-bookmarks = Tous les signets