diff --git a/package.json b/package.json index 81ec83be6..047b13852 100644 --- a/package.json +++ b/package.json @@ -121,6 +121,7 @@ "react-day-picker": "^8.10.1", "react-dom": "^18.3.1", "react-error-boundary": "^5.0.0", + "react-helmet-async": "^3.0.0", "react-instantsearch-dom": "^6.40.4", "react-intersection-observer": "^9.16.0", "react-lazy-load-image-component": "^1.6.3", diff --git a/plan/achievement-sbts.md b/plan/achievement-sbts.md deleted file mode 100644 index 21de32500..000000000 --- a/plan/achievement-sbts.md +++ /dev/null @@ -1,26 +0,0 @@ -# Feature: Achievement SBTs - -## Summary -Auto-minted Soulbound Tokens as badges for platform milestones. Non-transferable, permanently tied to a user's principal. - -## Achievement Examples -- "First Upload" — minted your first OG NFT -- "Prolific Creator" — uploaded 10 / 50 / 100 OG NFTs -- "Popular Work" — one of your works got 100+ Scions -- "Curator" — created 10+ shelves with items -- "Bibliophile" — minted Scions from 25+ different works -- "OG Member" — account created before a certain date - -## Display Locations -- User Profile page (`/dashboard/profile`) — dedicated badges section -- Alexandrian browser — achievement SBTs show up when filtering by SBT - -## Affected Canisters -| Canister | Impact | Why | -|----------|--------|-----| -| nft_manager | **Major** | Achievement definitions, threshold checks, triggers minting after actions | -| icrc7_scion | **None** | Just receives mint calls — achievements are Scions with distinct metadata | -| feed | **Read-only** | Already has SBT counts per OG NFT — queried for "Popular Work" check | -| user | **Read-only** | Already has `created_at` — queried for "OG Member" check | -| perpetua | **Read-only** | Already has user shelves — queried for "Curator" check | - diff --git a/plan/decentralized-book-clubs.md b/plan/decentralized-book-clubs.md deleted file mode 100644 index 058e69755..000000000 --- a/plan/decentralized-book-clubs.md +++ /dev/null @@ -1,27 +0,0 @@ -# Feature: Decentralized Book Clubs - -## Summary -Time-bounded reading groups around a book or shelf. Members read together, discuss in scoped threads, and earn a group completion SBT when the club ends. - -## How It Works -1. User creates a book club — picks a book, sets a time window (e.g. 2 weeks) -2. Other users join -3. Members discuss in threads scoped to the club -4. When time expires, members with 90%+ reading progress earn a completion SBT -5. Club becomes archived but viewable - -## Display Locations -- New "Book Clubs" section in Lbry app — browse, create, join -- Club detail page — book, members, discussion, progress, countdown - -## Affected Canisters -| Canister | Impact | Why | -|----------|--------|-----| -| perpetua | **Major** | Club lifecycle (create/join/leave/complete) — clubs are essentially time-bounded shelves with membership | -| alex_backend | **Medium** | Club-scoped discussion threads (reuses existing comment pattern, indexed by club ID) | -| nft_manager | **Minor** | Batch mint completion SBTs for qualifying members | -| icrc7_scion | **None** | Receives mint calls with club metadata | - -## Dependencies -- **Proof of Engagement** — club completion checks reading progress from alex_backend - diff --git a/plan/librarian-staking.md b/plan/librarian-staking.md deleted file mode 100644 index 8531b71c2..000000000 --- a/plan/librarian-staking.md +++ /dev/null @@ -1,34 +0,0 @@ -# Feature: Librarian Staking - -## Summary -Users who hit contribution thresholds earn a Librarian SBT. Librarians become stakeable — other users stake ALEX on them to earn a share of their Scion minting revenue. Higher-staked Librarians get boosted visibility. - -## How It Works -1. User hits threshold (e.g. 50+ OG NFTs uploaded) → earns Librarian SBT -2. Librarians appear in a stakeable creators interface -3. Users browse Librarians and stake ALEX on ones they trust -4. When Scions are minted from a Librarian's content, stakers earn a proportional cut -5. Librarians with higher total stake rank higher in discovery feeds - -## Display Locations -- New "Librarians" browse page — see all Librarians, their stats, total stake -- Staking interface — stake/unstake ALEX on a specific Librarian -- Feed/Alexandrian — stake-weighted ranking boost - -## Affected Canisters -| Canister | Impact | Why | -|----------|--------|-----| -| tokenomics | **Major** | Per-Librarian stake tracking instead of just global pool — new staking/unstaking logic, reward distribution to stakers | -| nft_manager | **Medium** | Route a % of Scion minting fees to Librarian's staker pool. Also triggers Librarian SBT minting at threshold | -| feed | **Medium** | Stake-weighted ranking for Librarians in discovery feeds | -| user | **Minor** | `librarian` field already exists — could be derived from SBT ownership instead of manually set | -| icrc7_scion | **None** | Receives Librarian SBT mint calls | - -## Dependencies -- **Achievement SBTs** — Librarian SBT is a high-tier achievement - -## Notes -- Current tokenomics has global staking only — this adds per-entity staking which is a significant extension -- Revenue split needs careful design: what % goes to stakers vs Librarian vs burn -- The `user.librarian` boolean field already exists — this gives it on-chain economic meaning - diff --git a/plan/proof-of-engagement.md b/plan/proof-of-engagement.md deleted file mode 100644 index bfdff4432..000000000 --- a/plan/proof-of-engagement.md +++ /dev/null @@ -1,27 +0,0 @@ -# Feature: Proof of Engagement (Books) - -## Summary -SBTs that prove a user finished reading a book. Reading progress tracked in the frontend, recorded on-chain, mintable as a permanent badge. - -## How It Works -1. Book reader tracks reading progress (page/chapter completion) -2. Progress is periodically saved to a canister -3. At 90%+ completion, user can mint a "Finished Reading" SBT -4. One per user per book - -## Display Locations -- Profile page — "Books Read" section -- NFT detail page — "X readers finished this book" (creator analytics) - -## Affected Canisters -| Canister | Impact | Why | -|----------|--------|-----| -| alex_backend | **Major** | New reading progress storage and tracking (fits alongside existing views/impressions) | -| nft_manager | **Minor** | New function to mint engagement SBT after verifying completion via alex_backend | -| icrc7_scion | **None** | Receives mint calls with engagement metadata | - -## Notes -- Progress tracking is client-side / trust-based — acceptable for social signals, not financial -- Feeds into Achievement SBTs ("Bibliophile" badge = completed N books) -- Scope: Books only (not Sonora/Syllogos/Dialectica) - diff --git a/src/alex_backend/alex_backend.did b/src/alex_backend/alex_backend.did index 4f8b8b121..011f2c1a8 100644 --- a/src/alex_backend/alex_backend.did +++ b/src/alex_backend/alex_backend.did @@ -22,6 +22,7 @@ type CommentInfo = record { created_at : nat64; comment : text; }; +type EmbeddingEntry = record { embedding : vec float32; arweave_id : text }; type HttpRequest = record { url : text; method : text; @@ -46,7 +47,10 @@ type Result_3 = variant { Ok : nat64; Err : ActivityError }; type Result_4 = variant { Ok : ReactionCounts; Err : ActivityError }; type Result_5 = variant { Ok : opt ReactionType; Err : ActivityError }; type Result_6 = variant { Ok; Err : ActivityError }; -type Result_7 = variant { Ok : text; Err : text }; +type Result_7 = variant { Ok : vec SimilarityResult; Err : text }; +type Result_8 = variant { Ok : text; Err : text }; +type SimilarityResult = record { score : float32; arweave_id : text }; +type StoreResult = record { stored : nat64; errors : vec text }; type UserNFTInfo = record { "principal" : principal; username : text; @@ -59,17 +63,31 @@ service : () -> { add_comment : (text, text) -> (Result); // Add or update a reaction to an NFT add_reaction : (text, ReactionType) -> (Result); + // Clear all embeddings. Controller only. + clear_embeddings : () -> (nat64); // Get all activities for a specific NFT get_activities : (text) -> (Result_1) query; // Get a specific activity by ID get_activity : (nat64) -> (Result) query; // Get all comments for a specific NFT get_comments : (text) -> (Result_2) query; + // Get total number of indexed embeddings + get_embedding_count : () -> (nat64) query; // Get the impression count for an article get_impressions : (text) -> (Result_3) query; + // Get all indexed arweave IDs + get_indexed_ids : () -> (vec text) query; // Get aggregated reaction counts for a specific NFT get_reaction_counts : (text) -> (Result_4) query; get_stored_nft_users : () -> (vec UserNFTInfo) query; + // Get trending content ranked by authenticated-viewer count (descending). + // Returns a list of (arweave_id, count) tuples, limited to `limit` results. + // + // Ranking uses `Some(principal)` entries only — anonymous views (`None`) are + // intentionally excluded so trending cannot be gamed by one anon user + // refreshing in a loop. `get_view_count` still reflects the full total for + // display purposes. + get_trending : (nat64) -> (vec record { text; nat64 }) query; // Get all activities by a specific user get_user_activities : (principal) -> (Result_1) query; // Get the current user's reaction for a specific NFT @@ -79,6 +97,16 @@ service : () -> { // Get the view count for an article get_view_count : (text) -> (Result_3) query; http_request : (HttpRequest) -> (HttpResponse) query; + // Check if an arweave_id has been indexed + is_indexed : (text) -> (bool) query; + // Record impressions and views in a single batched call. + // Impressions: counted unconditionally — no per-caller dedup. Every entry in the + // `impressions` vec increments the counter, so repeated calls + // (refresh, multiple tabs) inflate the total. Treat the number as + // a raw signal, not a unique-user metric. + // Views: deduped per authenticated caller (same as `record_view`). Anonymous + // views are counted without dedup. + record_engagement_batch : (vec text, vec text) -> (Result_6); // Record an impression for an article (article appeared in feed) // Anyone can call, always increments counter record_impression : (text) -> (Result_3); @@ -89,10 +117,20 @@ service : () -> { record_view : (text) -> (Result_3); // Remove a comment (only by the comment author) remove_comment : (nat64) -> (Result_6); + // Remove embeddings. Controller only. + remove_embeddings : (vec text) -> (nat64); // Remove a user's reaction from an NFT remove_reaction : (text) -> (Result_6); - start_alex_supply_timer : () -> (Result_7); - update_alex_supply : () -> (Result_7); + // Reset embeddings by reinitializing the BTreeMap. Use if data is corrupted. Controller only. + reset_embeddings : () -> (text); + // Search by a pre-computed embedding vector (for text-to-image search) + search_by_vector : (vec float32, nat32) -> (Result_7) query; + // Search for NFTs with similar images to the given arweave_id + search_similar : (text, nat32) -> (Result_7) query; + start_alex_supply_timer : () -> (Result_8); + // Store embeddings for NFT images. Controller only. + store_embeddings : (vec EmbeddingEntry) -> (StoreResult); + update_alex_supply : () -> (Result_8); // Update a comment (only by the comment author) update_comment : (nat64, text) -> (Result); // Get the caller's principal diff --git a/src/alex_backend/src/dialectica/api/queries.rs b/src/alex_backend/src/dialectica/api/queries.rs index f94397abc..656ccc1ff 100644 --- a/src/alex_backend/src/dialectica/api/queries.rs +++ b/src/alex_backend/src/dialectica/api/queries.rs @@ -229,4 +229,31 @@ pub fn get_view_count(arweave_id: String) -> ActivityResult { None => Ok(0), } }) +} + +/// Get trending content ranked by authenticated-viewer count (descending). +/// Returns a list of (arweave_id, count) tuples, limited to `limit` results. +/// +/// Ranking uses `Some(principal)` entries only — anonymous views (`None`) are +/// intentionally excluded so trending cannot be gamed by one anon user +/// refreshing in a loop. `get_view_count` still reflects the full total for +/// display purposes. +#[query] +pub fn get_trending(limit: u64) -> Vec<(String, u64)> { + let max_limit = limit.min(100) as usize; + + VIEWS.with(|views| { + let views = views.borrow(); + let mut entries: Vec<(String, u64)> = views + .iter() + .map(|(key, viewers)| { + let authed = viewers.0.0.iter().flatten().count() as u64; + (key.0.clone(), authed) + }) + .collect(); + + entries.sort_by(|a, b| b.1.cmp(&a.1)); + entries.truncate(max_limit); + entries + }) } \ No newline at end of file diff --git a/src/alex_backend/src/dialectica/api/updates.rs b/src/alex_backend/src/dialectica/api/updates.rs index 44dab4939..d617353f1 100644 --- a/src/alex_backend/src/dialectica/api/updates.rs +++ b/src/alex_backend/src/dialectica/api/updates.rs @@ -333,6 +333,61 @@ pub fn update_comment(activity_id: u64, new_comment: String) -> ActivityResult, views: Vec) -> ActivityResult<()> { + let caller = caller(); + let is_anonymous = caller == Principal::anonymous(); + + // Process impressions — counter-only, no dedup (see fn-level docs) + IMPRESSIONS.with(|imp_store| { + let mut imp_store = imp_store.borrow_mut(); + for arweave_id in &impressions { + if arweave_id.trim().is_empty() || arweave_id.len() != 43 { + continue; + } + let current = imp_store.get(&StorableString(arweave_id.clone())).unwrap_or(0); + imp_store.insert(StorableString(arweave_id.clone()), current + 1); + } + }); + + // Process views — dedup per authenticated user + VIEWS.with(|view_store| { + let mut view_store = view_store.borrow_mut(); + for arweave_id in &views { + if arweave_id.trim().is_empty() || arweave_id.len() != 43 { + continue; + } + let mut viewers = match view_store.get(&StorableString(arweave_id.clone())) { + Some(list) => list.0.0, + None => Vec::new(), + }; + + if is_anonymous { + viewers.push(None); + } else { + let already_viewed = viewers.iter().any(|v| matches!(v, Some(p) if *p == caller)); + if !already_viewed { + viewers.push(Some(caller)); + } + } + + view_store.insert( + StorableString(arweave_id.clone()), + StorableViewersList(ViewersList(viewers)), + ); + } + }); + + Ok(()) +} + /// Record an impression for an article (article appeared in feed) /// Anyone can call, always increments counter #[update] diff --git a/src/alex_backend/src/dialectica/store.rs b/src/alex_backend/src/dialectica/store.rs index d93120736..fb8deb0db 100644 --- a/src/alex_backend/src/dialectica/store.rs +++ b/src/alex_backend/src/dialectica/store.rs @@ -1,11 +1,12 @@ use candid::{CandidType, Decode, Encode, Principal}; -use ic_stable_structures::memory_manager::{MemoryId, MemoryManager, VirtualMemory}; +use ic_stable_structures::memory_manager::{MemoryId, VirtualMemory}; use ic_stable_structures::{DefaultMemoryImpl, StableBTreeMap, Storable}; use std::borrow::Cow; use std::cell::RefCell; use serde::{Serialize, Deserialize}; use super::models::activity::Activity; +use crate::MEMORY_MANAGER; type Memory = VirtualMemory; @@ -144,10 +145,6 @@ impl Storable for StorableUserReactionKey { } thread_local! { - static MEMORY_MANAGER: RefCell> = RefCell::new( - MemoryManager::init(DefaultMemoryImpl::default()) - ); - // Main activities storage: activity_id -> Activity pub static ACTIVITIES: RefCell> = RefCell::new( StableBTreeMap::init( diff --git a/src/alex_backend/src/lib.rs b/src/alex_backend/src/lib.rs index 214e53fbd..e28b9d649 100644 --- a/src/alex_backend/src/lib.rs +++ b/src/alex_backend/src/lib.rs @@ -11,6 +11,9 @@ pub use nft_users::{UserNFTInfo, get_stored_nft_users}; // Dialectica module - social features (reactions, comments, views, impressions) pub mod dialectica; +// Similarity module - NFT image similarity search using CLIP embeddings +pub mod similarity; + // Re-export dialectica types for candid export pub use dialectica::{ Activity, ActivityType, ActivityError, ActivityResult, @@ -18,6 +21,10 @@ pub use dialectica::{ AddCommentRequest, AddReactionRequest, ActivityResponse, UpdateCommentRequest, }; +pub use similarity::{ + SimilarityResult, EmbeddingEntry, StoreResult, +}; + pub const ICRC7_CANISTER_ID: &str = "53ewn-qqaaa-aaaap-qkmqq-cai"; pub const ICRC7_SCION_CANISTER_ID: &str = "uxyan-oyaaa-aaaap-qhezq-cai"; pub const USER_CANISTER_ID: &str = "yo4hu-nqaaa-aaaap-qkmoq-cai"; @@ -27,7 +34,7 @@ pub const ALEX_TOKEN_CANISTER_ID: &str = "ysy5f-2qaaa-aaaap-qkmmq-cai"; type Memory = ic_stable_structures::memory_manager::VirtualMemory; thread_local! { - static MEMORY_MANAGER: RefCell> = RefCell::new( + pub static MEMORY_MANAGER: RefCell> = RefCell::new( MemoryManager::init(DefaultMemoryImpl::default()) ); diff --git a/src/alex_backend/src/nft_users.rs b/src/alex_backend/src/nft_users.rs index 9fd0b7c9b..3174cb754 100644 --- a/src/alex_backend/src/nft_users.rs +++ b/src/alex_backend/src/nft_users.rs @@ -1,17 +1,15 @@ use candid::{CandidType, Deserialize, Principal}; -use ic_stable_structures::memory_manager::{MemoryId, MemoryManager, VirtualMemory}; +use ic_stable_structures::memory_manager::{MemoryId, VirtualMemory}; use ic_stable_structures::{DefaultMemoryImpl, StableBTreeMap, Storable}; use ic_stable_structures::storable::Bound; use std::cell::RefCell; use ic_cdk::api::call::CallResult; +use crate::MEMORY_MANAGER; + const MAX_VALUE_SIZE: u32 = 256; thread_local! { - static MEMORY_MANAGER: RefCell> = RefCell::new( - MemoryManager::init(DefaultMemoryImpl::default()) - ); - static NFT_USERS: RefCell>> = RefCell::new(StableBTreeMap::init( MEMORY_MANAGER.with(|m| m.borrow().get(MemoryId::new(0))), @@ -134,4 +132,5 @@ pub fn get_stored_nft_users() -> Vec { .map(|(_, value)| value.clone()) .collect() }) -} \ No newline at end of file +} + diff --git a/src/alex_backend/src/similarity/mod.rs b/src/alex_backend/src/similarity/mod.rs new file mode 100644 index 000000000..aacde596e --- /dev/null +++ b/src/alex_backend/src/similarity/mod.rs @@ -0,0 +1,7 @@ +pub mod store; +pub mod queries; +pub mod updates; + +pub use store::*; +pub use queries::*; +pub use updates::*; diff --git a/src/alex_backend/src/similarity/queries.rs b/src/alex_backend/src/similarity/queries.rs new file mode 100644 index 000000000..6599299b5 --- /dev/null +++ b/src/alex_backend/src/similarity/queries.rs @@ -0,0 +1,99 @@ +use candid::CandidType; +use ic_cdk::query; +use serde::Serialize; + +use super::store::{cosine_similarity, ArweaveKey, EMBEDDINGS}; + +/// A single similarity result +#[derive(CandidType, Serialize, Clone)] +pub struct SimilarityResult { + pub arweave_id: String, + pub score: f32, +} + +/// Search for NFTs with similar images to the given arweave_id +#[query] +fn search_similar(arweave_id: String, top_k: u32) -> Result, String> { + let top_k = top_k.min(100) as usize; + + let query_embedding = EMBEDDINGS.with(|emb| { + emb.borrow() + .get(&ArweaveKey(arweave_id.clone())) + .map(|e| e.embedding.clone()) + }); + + let query_vec = query_embedding.ok_or_else(|| format!("{} not indexed", arweave_id))?; + + let mut results: Vec = EMBEDDINGS.with(|emb| { + let map = emb.borrow(); + let mut scored = Vec::new(); + + for (key, stored) in map.iter() { + if key.0 == arweave_id { + continue; + } + let score = cosine_similarity(&query_vec, &stored.embedding); + scored.push(SimilarityResult { + arweave_id: key.0.clone(), + score, + }); + } + scored + }); + + results.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal)); + results.truncate(top_k); + Ok(results) +} + +/// Search by a pre-computed embedding vector (for text-to-image search) +#[query] +fn search_by_vector(embedding: Vec, top_k: u32) -> Result, String> { + let top_k = top_k.min(100) as usize; + + if embedding.len() != super::store::EMBEDDING_DIM { + return Err(format!( + "Expected {} dimensions, got {}", + super::store::EMBEDDING_DIM, + embedding.len() + )); + } + + let mut results: Vec = EMBEDDINGS.with(|emb| { + let map = emb.borrow(); + let mut scored = Vec::new(); + + for (key, stored) in map.iter() { + let score = cosine_similarity(&embedding, &stored.embedding); + scored.push(SimilarityResult { + arweave_id: key.0.clone(), + score, + }); + } + scored + }); + + results.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal)); + results.truncate(top_k); + Ok(results) +} + +/// Get total number of indexed embeddings +#[query] +fn get_embedding_count() -> u64 { + EMBEDDINGS.with(|emb| emb.borrow().len()) +} + +/// Check if an arweave_id has been indexed +#[query] +fn is_indexed(arweave_id: String) -> bool { + EMBEDDINGS.with(|emb| emb.borrow().contains_key(&ArweaveKey(arweave_id))) +} + +/// Get all indexed arweave IDs +#[query] +fn get_indexed_ids() -> Vec { + EMBEDDINGS.with(|emb| { + emb.borrow().iter().map(|(key, _)| key.0.clone()).collect() + }) +} diff --git a/src/alex_backend/src/similarity/store.rs b/src/alex_backend/src/similarity/store.rs new file mode 100644 index 000000000..96955e738 --- /dev/null +++ b/src/alex_backend/src/similarity/store.rs @@ -0,0 +1,88 @@ +use candid::{CandidType, Decode, Encode}; +use ic_stable_structures::memory_manager::{MemoryId, VirtualMemory}; +use ic_stable_structures::{DefaultMemoryImpl, StableBTreeMap, Storable}; +use serde::{Deserialize, Serialize}; +use std::borrow::Cow; +use std::cell::RefCell; + +use crate::MEMORY_MANAGER; + +type Memory = VirtualMemory; + +// Memory IDs starting from 30 to avoid conflicts (10=supply, 20-26=dialectica) +pub const EMBEDDINGS_MEM_ID: MemoryId = MemoryId::new(30); + +// CLIP ViT-B-32 produces 512-dimensional vectors +pub const EMBEDDING_DIM: usize = 512; + +// Max size: 43 bytes arweave_id key + 512 * 4 bytes floats + overhead +const MAX_EMBEDDING_SIZE: u32 = 4096; + +/// Stored embedding for a single NFT image +#[derive(CandidType, Deserialize, Serialize, Clone, Debug)] +pub struct StoredEmbedding { + pub embedding: Vec, +} + +impl Storable for StoredEmbedding { + fn to_bytes(&self) -> Cow<[u8]> { + Cow::Owned(Encode!(self).unwrap()) + } + + fn from_bytes(bytes: Cow<[u8]>) -> Self { + Decode!(bytes.as_ref(), Self).unwrap() + } + + const BOUND: ic_stable_structures::storable::Bound = + ic_stable_structures::storable::Bound::Bounded { + max_size: MAX_EMBEDDING_SIZE, + is_fixed_size: false, + }; +} + +/// Key wrapper for arweave IDs (max 43 chars) +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct ArweaveKey(pub String); + +impl Storable for ArweaveKey { + fn to_bytes(&self) -> Cow<[u8]> { + Cow::Owned(Encode!(&self.0).unwrap()) + } + + fn from_bytes(bytes: Cow<[u8]>) -> Self { + Self(Decode!(bytes.as_ref(), String).unwrap()) + } + + const BOUND: ic_stable_structures::storable::Bound = + ic_stable_structures::storable::Bound::Bounded { + max_size: 64, + is_fixed_size: false, + }; +} + +thread_local! { + pub static EMBEDDINGS: RefCell> = RefCell::new( + StableBTreeMap::init( + MEMORY_MANAGER.with(|m| m.borrow().get(EMBEDDINGS_MEM_ID)) + ) + ); +} + +pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 { + let mut dot: f32 = 0.0; + let mut norm_a: f32 = 0.0; + let mut norm_b: f32 = 0.0; + + for i in 0..a.len().min(b.len()) { + dot += a[i] * b[i]; + norm_a += a[i] * a[i]; + norm_b += b[i] * b[i]; + } + + let denom = norm_a.sqrt() * norm_b.sqrt(); + if denom == 0.0 { + 0.0 + } else { + dot / denom + } +} diff --git a/src/alex_backend/src/similarity/updates.rs b/src/alex_backend/src/similarity/updates.rs new file mode 100644 index 000000000..c2fa01847 --- /dev/null +++ b/src/alex_backend/src/similarity/updates.rs @@ -0,0 +1,112 @@ +use candid::{CandidType, Principal}; +use ic_cdk::{caller, update}; +use serde::Deserialize; + +use super::store::{ArweaveKey, StoredEmbedding, EMBEDDING_DIM, EMBEDDINGS}; + +/// Single embedding entry for batch storage +#[derive(CandidType, Deserialize, Clone)] +pub struct EmbeddingEntry { + pub arweave_id: String, + pub embedding: Vec, +} + +/// Result of a store operation +#[derive(CandidType)] +pub struct StoreResult { + pub stored: u64, + pub errors: Vec, +} + +fn is_controller() -> Result<(), String> { + // ic_cdk::api::is_controller checks if caller is a canister controller + if ic_cdk::api::is_controller(&caller()) { + Ok(()) + } else { + Err("Only controllers can call this method".to_string()) + } +} + +/// Store embeddings for NFT images. Controller only. +#[update(guard = "is_controller")] +fn store_embeddings(entries: Vec) -> StoreResult { + let mut stored: u64 = 0; + let mut errors: Vec = Vec::new(); + + for entry in entries { + if entry.embedding.len() != EMBEDDING_DIM { + errors.push(format!( + "{}: expected {} dimensions, got {}", + entry.arweave_id, + EMBEDDING_DIM, + entry.embedding.len() + )); + continue; + } + + if entry.arweave_id.is_empty() || entry.arweave_id.len() > 43 { + errors.push(format!("{}: invalid arweave_id length", entry.arweave_id)); + continue; + } + + EMBEDDINGS.with(|emb| { + emb.borrow_mut().insert( + ArweaveKey(entry.arweave_id), + StoredEmbedding { + embedding: entry.embedding, + }, + ); + }); + stored += 1; + } + + StoreResult { + stored, + errors, + } +} + +/// Remove embeddings. Controller only. +#[update(guard = "is_controller")] +fn remove_embeddings(arweave_ids: Vec) -> u64 { + let mut removed: u64 = 0; + EMBEDDINGS.with(|emb| { + let mut map = emb.borrow_mut(); + for id in arweave_ids { + if map.remove(&ArweaveKey(id)).is_some() { + removed += 1; + } + } + }); + removed +} + +/// Clear all embeddings. Controller only. +#[update(guard = "is_controller")] +fn clear_embeddings() -> u64 { + EMBEDDINGS.with(|emb| { + let mut map = emb.borrow_mut(); + let count = map.len(); + let keys: Vec = map.iter().map(|(k, _)| k.clone()).collect(); + for key in keys { + map.remove(&key); + } + count + }) +} + +/// Reset embeddings by reinitializing the BTreeMap. Use if data is corrupted. Controller only. +#[update(guard = "is_controller")] +fn reset_embeddings() -> String { + use crate::MEMORY_MANAGER; + use super::store::EMBEDDINGS_MEM_ID; + + EMBEDDINGS.with(|emb| { + let old_count = emb.borrow().len(); + *emb.borrow_mut() = ic_stable_structures::StableBTreeMap::new( + MEMORY_MANAGER.with(|m| m.borrow().get(EMBEDDINGS_MEM_ID)) + ); + let new_count = emb.borrow().len(); + format!("Reset done. Old count: {}, New count: {}", old_count, new_count) + }) +} diff --git a/src/alex_frontend/core/components/NftProvider/index.tsx b/src/alex_frontend/core/components/NftProvider/index.tsx index 4fdc246dd..5cfd26b44 100644 --- a/src/alex_frontend/core/components/NftProvider/index.tsx +++ b/src/alex_frontend/core/components/NftProvider/index.tsx @@ -51,7 +51,7 @@ const NftProvider = ({ safe, loading, items, children, empty }: NftProviderP if (currentIndex > 0) { const prevItem = items[currentIndex - 1] as any; const prevArweaveId = prevItem.arweaveId || prevItem.arweave_id || prevItem.id; - setModal({ id: prevArweaveId }); + setModal({ id: prevArweaveId, token: prevItem }); } }; @@ -61,7 +61,7 @@ const NftProvider = ({ safe, loading, items, children, empty }: NftProviderP if (currentIndex < items.length - 1) { const nextItem = items[currentIndex + 1] as any; const nextArweaveId = nextItem.arweaveId || nextItem.arweave_id || nextItem.id; - setModal({ id: nextArweaveId }); + setModal({ id: nextArweaveId, token: nextItem }); } }; diff --git a/src/alex_frontend/core/components/UsernameBadge.tsx b/src/alex_frontend/core/components/UsernameBadge.tsx index 9ee5ac6ba..6b1c255c9 100644 --- a/src/alex_frontend/core/components/UsernameBadge.tsx +++ b/src/alex_frontend/core/components/UsernameBadge.tsx @@ -4,6 +4,7 @@ import { AtSign } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; import { user } from "../../../declarations/user"; import { Principal } from "@dfinity/principal"; +import { Link } from "@tanstack/react-router"; interface UsernameBadgeProps { principal: string; @@ -28,13 +29,15 @@ const UsernameBadge: React.FC = ({ principal, className = "" } return ( - - - {data.Ok.username} - + + + + {data.Ok.username} + + ); }; diff --git a/src/alex_frontend/core/features/alexandrian/adapters/TokenAdapter.ts b/src/alex_frontend/core/features/alexandrian/adapters/TokenAdapter.ts index f1e112698..3d53bce7b 100644 --- a/src/alex_frontend/core/features/alexandrian/adapters/TokenAdapter.ts +++ b/src/alex_frontend/core/features/alexandrian/adapters/TokenAdapter.ts @@ -141,10 +141,11 @@ class NFTAdapter implements TokenAdapter { }; // Fetch all data concurrently - const [alexBalance, lbryBalance, rarityResult] = await Promise.allSettled([ + const [alexBalance, lbryBalance, rarityResult, scionCountResult] = await Promise.allSettled([ ALEX.icrc1_balance_of(balanceParams), LBRY.icrc1_balance_of(balanceParams), feed.get_rarity_percentages_for_og_nfts([tokenId]), + feed.get_sbt_counts_for_og_nfts([tokenId]), ]); // Process results @@ -159,7 +160,15 @@ class NFTAdapter implements TokenAdapter { rank = Number(rarityData[1]) || undefined; } } - return { alex: alexBalanceValue, lbry: lbryBalanceValue, rank }; + + let scionCount: number | undefined = undefined; + if (scionCountResult.status === "fulfilled" && scionCountResult.value.length > 0) { + const countData = scionCountResult.value[0]; + if (countData && countData.length > 0) { + scionCount = Number(countData[1]) || undefined; + } + } + return { alex: alexBalanceValue, lbry: lbryBalanceValue, rank, scionCount }; } catch (error) { console.warn(`Error fetching ICP info for NFT ${tokenId}:`, error); return { alex: 0, lbry: 0 }; diff --git a/src/alex_frontend/core/features/alexandrian/alexandrianSlice.ts b/src/alex_frontend/core/features/alexandrian/alexandrianSlice.ts index 03db1d354..0b732699d 100644 --- a/src/alex_frontend/core/features/alexandrian/alexandrianSlice.ts +++ b/src/alex_frontend/core/features/alexandrian/alexandrianSlice.ts @@ -61,7 +61,7 @@ const alexandrianSlice = createSlice({ // Sorting actions setSortBy: ( state, - action: PayloadAction<"default" | "alex" | "lbry"> + action: PayloadAction<"default" | "alex" | "lbry" | "trending"> ) => { state.sortBy = action.payload; state.page = 0; // Reset to first page when sort changes diff --git a/src/alex_frontend/core/features/alexandrian/api/createTokenFetcher.ts b/src/alex_frontend/core/features/alexandrian/api/createTokenFetcher.ts index 70f57a99c..9500f5dad 100644 --- a/src/alex_frontend/core/features/alexandrian/api/createTokenFetcher.ts +++ b/src/alex_frontend/core/features/alexandrian/api/createTokenFetcher.ts @@ -6,6 +6,7 @@ import { convertTokenData } from "../utils/convertTokenData"; import { sortTokensByBalance } from "../utils/sortTokens"; import { fetchBalances } from "./fetchBalances"; import { calculatePagination } from "../utils/calculatePagination"; +import { alex_backend } from "../../../../../declarations/alex_backend"; // Create the main fetcher function with type-safe parameters export const createTokenFetcher = () => { @@ -56,6 +57,21 @@ export const createTokenFetcher = () => { if (signal?.aborted) throw new Error('Request cancelled'); sortedTokens = sortTokensByBalance(tokens, balances, params.sortBy, params.sortOrder); + } else if (params.sortBy === 'trending') { + // Fetch trending arweave IDs sorted by views + const trendingEntries = await alex_backend.get_trending(BigInt(100)); + const trendingOrder = new Map( + trendingEntries.map(([arweaveId, viewCount]: [string, bigint], index: number) => [arweaveId, index]) + ); + + const tokenEntries = Object.entries(tokens); + tokenEntries.sort(([, a], [, b]) => { + const aIndex = trendingOrder.has(a.arweaveId) ? trendingOrder.get(a.arweaveId)! : Infinity; + const bIndex = trendingOrder.has(b.arweaveId) ? trendingOrder.get(b.arweaveId)! : Infinity; + return aIndex - bIndex; + }); + + sortedTokens = Object.fromEntries(tokenEntries); } // For 'default' sorting, tokens are already sorted by the API, so no action needed diff --git a/src/alex_frontend/core/features/alexandrian/components/FilterBar.tsx b/src/alex_frontend/core/features/alexandrian/components/FilterBar.tsx index 104738604..678ac36cf 100644 --- a/src/alex_frontend/core/features/alexandrian/components/FilterBar.tsx +++ b/src/alex_frontend/core/features/alexandrian/components/FilterBar.tsx @@ -1,19 +1,38 @@ -import React from "react"; +import React, { useState } from "react"; import { SortOrderToggle, CollectionTypeToggle, UserSelector, } from "./Filters"; +import { Input } from "@/lib/components/input"; import { Button } from "@/lib/components/button"; -import { RotateCw } from "lucide-react"; +import { RotateCw, Search, X } from "lucide-react"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/lib/components/tooltip"; interface FilterBarProps { disabled?: boolean; onRefresh: () => void; + onSearch?: (query: string) => void; + onClearSearch?: () => void; + searching?: boolean; } -const FilterBar: React.FC = ({ disabled, onRefresh }) => { +const FilterBar: React.FC = ({ disabled, onRefresh, onSearch, onClearSearch, searching }) => { + const [query, setQuery] = useState(""); + + const handleSearch = () => { + if (query.trim() && onSearch) onSearch(query.trim()); + }; + + const handleClear = () => { + setQuery(""); + if (onClearSearch) onClearSearch(); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") handleSearch(); + }; + return (
@@ -22,6 +41,41 @@ const FilterBar: React.FC = ({ disabled, onRefresh }) => {
+
+
+ setQuery(e.target.value)} + onKeyDown={handleKeyDown} + disabled={disabled || searching} + className="pr-8 h-10" + variant="default" + scale="default" + rounded="md" + /> + {query && ( + + )} +
+ +
+ + + )}