diff --git a/Cargo.toml b/Cargo.toml index 6a67522ed..6a9a44689 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,13 +12,11 @@ members = [ "src/logs", "src/xrc", "src/user", - "src/dialectica", "src/alex_wallet", "src/emporium", "src/logs", "src/asset_manager", "src/feed", "src/alex_revshare", - "src/kairos", ] resolver = "2" diff --git a/Makefile b/Makefile index 9a444c19f..4c0e92b76 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all clean fresh ii xrc icrc7 icrc7-scion nft-manager alex-backend perpetua feed icp-swap tokenomics user system-api alex-wallet vetkd emporium logs asset-manager alex-revshare dialectica kairos ensure-identities clean-identities test icp-ledger lbry alex tokens frontend help +.PHONY: all clean fresh ii xrc icrc7 icrc7-scion nft-manager alex-backend perpetua feed icp-swap tokenomics user system-api alex-wallet vetkd emporium logs asset-manager alex-revshare kairos ensure-identities clean-identities test icp-ledger lbry alex tokens frontend help # Start dfx and basic setup clean: @@ -163,14 +163,6 @@ alex-revshare: candid-extractor target/wasm32-unknown-unknown/release/alex_revshare.wasm > src/alex_revshare/alex_revshare.did dfx deploy alex_revshare --specified-id e454q-riaaa-aaaap-qqcyq-cai -# Deploy Dialectica -dialectica: - @echo "Deploying Dialectica..." - cargo build --release --target wasm32-unknown-unknown --package dialectica - candid-extractor target/wasm32-unknown-unknown/release/dialectica.wasm > src/dialectica/dialectica.did - dfx deploy dialectica - dfx generate dialectica - # Deploy Kairos kairos: @echo "Deploying Kairos..." @@ -293,7 +285,6 @@ help: @echo " logs - Deploy Logs" @echo " asset-manager - Deploy Asset Manager" @echo " alex-revshare - Deploy Alex Revshare" - @echo " dialectica - Deploy Dialectica canister" @echo " kairos - Deploy Kairos canister" @echo " ensure-identities - Ensure all required identities, Create if don't exist" @echo " clean-identities - Remove all project identities" diff --git a/dfx.json b/dfx.json index e6379b498..db11b4cd4 100644 --- a/dfx.json +++ b/dfx.json @@ -32,11 +32,6 @@ "package": "user", "candid": "src/user/user.did" }, - "dialectica": { - "type": "rust", - "package": "dialectica", - "candid": "src/dialectica/dialectica.did" - }, "alex_backend": { "candid": "src/alex_backend/alex_backend.did", "package": "alex_backend", @@ -145,11 +140,6 @@ "package": "alex_revshare", "type": "rust", "specified_id": "e454q-riaaa-aaaap-qqcyq-cai" - }, - "kairos": { - "type": "rust", - "package": "kairos", - "candid": "src/kairos/kairos.did" } }, "defaults": { diff --git a/src/alex_backend/alex_backend.did b/src/alex_backend/alex_backend.did index 6a2305c3b..4f8b8b121 100644 --- a/src/alex_backend/alex_backend.did +++ b/src/alex_backend/alex_backend.did @@ -1,3 +1,27 @@ +type Activity = record { + id : nat64; + activity_type : ActivityType; + updated_at : nat64; + user : principal; + created_at : nat64; + arweave_id : text; +}; +type ActivityError = variant { + AnonymousNotAllowed; + NotFound : nat64; + Unauthorized; + AlreadyExists; + InvalidArweaveId; + InternalError : text; + InvalidComment : text; +}; +type ActivityType = variant { Comment : text; Reaction : ReactionType }; +type CommentInfo = record { + id : nat64; + user : principal; + created_at : nat64; + comment : text; +}; type HttpRequest = record { url : text; method : text; @@ -9,7 +33,20 @@ type HttpResponse = record { headers : vec record { text; text }; status_code : nat16; }; -type Result = variant { Ok : text; Err : text }; +type ReactionCounts = record { + total_comments : nat64; + likes : nat64; + dislikes : nat64; +}; +type ReactionType = variant { Like; Dislike }; +type Result = variant { Ok : Activity; Err : ActivityError }; +type Result_1 = variant { Ok : vec Activity; Err : ActivityError }; +type Result_2 = variant { Ok : vec CommentInfo; Err : ActivityError }; +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 UserNFTInfo = record { "principal" : principal; username : text; @@ -18,8 +55,46 @@ type UserNFTInfo = record { has_nfts : bool; }; service : () -> { + // Add a comment to an NFT + add_comment : (text, text) -> (Result); + // Add or update a reaction to an NFT + add_reaction : (text, ReactionType) -> (Result); + // 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 the impression count for an article + get_impressions : (text) -> (Result_3) query; + // Get aggregated reaction counts for a specific NFT + get_reaction_counts : (text) -> (Result_4) query; get_stored_nft_users : () -> (vec UserNFTInfo) 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 + get_user_reaction : (text) -> (Result_5) query; + // Get a specific user's reaction for a specific NFT + get_user_reaction_for_principal : (text, principal) -> (Result_5) query; + // Get the view count for an article + get_view_count : (text) -> (Result_3) query; http_request : (HttpRequest) -> (HttpResponse) query; - start_alex_supply_timer : () -> (Result); - update_alex_supply : () -> (Result); + // Record an impression for an article (article appeared in feed) + // Anyone can call, always increments counter + record_impression : (text) -> (Result_3); + // Record a view for an article (user opened full article) + // Anyone can call + // - Authenticated users: deduplicated (only counted once per user) + // - Anonymous users: always added + record_view : (text) -> (Result_3); + // Remove a comment (only by the comment author) + remove_comment : (nat64) -> (Result_6); + // Remove a user's reaction from an NFT + remove_reaction : (text) -> (Result_6); + start_alex_supply_timer : () -> (Result_7); + update_alex_supply : () -> (Result_7); + // Update a comment (only by the comment author) + update_comment : (nat64, text) -> (Result); + // Get the caller's principal + whoami : () -> (principal) query; } diff --git a/src/dialectica/src/api/mod.rs b/src/alex_backend/src/dialectica/api/mod.rs similarity index 100% rename from src/dialectica/src/api/mod.rs rename to src/alex_backend/src/dialectica/api/mod.rs diff --git a/src/dialectica/src/api/queries.rs b/src/alex_backend/src/dialectica/api/queries.rs similarity index 86% rename from src/dialectica/src/api/queries.rs rename to src/alex_backend/src/dialectica/api/queries.rs index d2bb19dfe..f94397abc 100644 --- a/src/dialectica/src/api/queries.rs +++ b/src/alex_backend/src/dialectica/api/queries.rs @@ -2,10 +2,10 @@ use candid::Principal; use ic_cdk::api::caller; use ic_cdk_macros::query; -use crate::errors::activity::{ActivityError, ActivityResult}; -use crate::models::activity::{Activity, ActivityType, CommentInfo, ReactionCounts, ReactionType}; -use crate::store::{ - ACTIVITIES, ARWEAVE_ACTIVITIES, USER_ACTIVITIES, USER_REACTIONS, +use crate::dialectica::errors::activity::{ActivityError, ActivityResult}; +use crate::dialectica::models::activity::{Activity, ActivityType, CommentInfo, ReactionCounts, ReactionType}; +use crate::dialectica::store::{ + ACTIVITIES, ARWEAVE_ACTIVITIES, IMPRESSIONS, USER_ACTIVITIES, USER_REACTIONS, VIEWS, StorableString, StorablePrincipal, StorableUserReactionKey, UserReactionKey }; @@ -200,4 +200,33 @@ pub fn get_activity(activity_id: u64) -> ActivityResult { None => Err(ActivityError::NotFound(activity_id)), } }) +} + +/// Get the impression count for an article +#[query] +pub fn get_impressions(arweave_id: String) -> ActivityResult { + if arweave_id.trim().is_empty() || arweave_id.len() != 43 { + return Err(ActivityError::InvalidArweaveId); + } + + IMPRESSIONS.with(|impressions| { + let impressions = impressions.borrow(); + Ok(impressions.get(&StorableString(arweave_id)).unwrap_or(0)) + }) +} + +/// Get the view count for an article +#[query] +pub fn get_view_count(arweave_id: String) -> ActivityResult { + if arweave_id.trim().is_empty() || arweave_id.len() != 43 { + return Err(ActivityError::InvalidArweaveId); + } + + VIEWS.with(|views| { + let views = views.borrow(); + match views.get(&StorableString(arweave_id)) { + Some(viewers) => Ok(viewers.0.0.len() as u64), + None => Ok(0), + } + }) } \ No newline at end of file diff --git a/src/dialectica/src/api/updates.rs b/src/alex_backend/src/dialectica/api/updates.rs similarity index 84% rename from src/dialectica/src/api/updates.rs rename to src/alex_backend/src/dialectica/api/updates.rs index 844ec10ec..44dab4939 100644 --- a/src/dialectica/src/api/updates.rs +++ b/src/alex_backend/src/dialectica/api/updates.rs @@ -2,12 +2,13 @@ use candid::Principal; use ic_cdk::api::caller; use ic_cdk_macros::update; -use crate::errors::activity::{ActivityError, ActivityResult}; -use crate::models::activity::{Activity, ActivityType, ReactionType}; -use crate::store::{ +use crate::dialectica::errors::activity::{ActivityError, ActivityResult}; +use crate::dialectica::models::activity::{Activity, ActivityType, ReactionType}; +use crate::dialectica::store::{ get_next_activity_id, ActivityIdList, StorableActivity, StorableActivityIdList, - StorablePrincipal, StorableString, StorableUserReactionKey, UserReactionKey, - ACTIVITIES, ARWEAVE_ACTIVITIES, USER_ACTIVITIES, USER_REACTIONS, + StorablePrincipal, StorableString, StorableUserReactionKey, StorableViewersList, + UserReactionKey, ViewersList, + ACTIVITIES, ARWEAVE_ACTIVITIES, IMPRESSIONS, USER_ACTIVITIES, USER_REACTIONS, VIEWS, }; /// Add or update a reaction to an NFT @@ -330,4 +331,64 @@ pub fn update_comment(activity_id: u64, new_comment: String) -> ActivityResult Err(ActivityError::NotFound(activity_id)), } }) +} + +/// Record an impression for an article (article appeared in feed) +/// Anyone can call, always increments counter +#[update] +pub fn record_impression(arweave_id: String) -> ActivityResult { + if arweave_id.trim().is_empty() || arweave_id.len() != 43 { + return Err(ActivityError::InvalidArweaveId); + } + + IMPRESSIONS.with(|impressions| { + let mut impressions = impressions.borrow_mut(); + let current = impressions.get(&StorableString(arweave_id.clone())).unwrap_or(0); + let new_count = current + 1; + impressions.insert(StorableString(arweave_id), new_count); + Ok(new_count) + }) +} + +/// Record a view for an article (user opened full article) +/// Anyone can call +/// - Authenticated users: deduplicated (only counted once per user) +/// - Anonymous users: always added +#[update] +pub fn record_view(arweave_id: String) -> ActivityResult { + let caller = caller(); + + if arweave_id.trim().is_empty() || arweave_id.len() != 43 { + return Err(ActivityError::InvalidArweaveId); + } + + VIEWS.with(|views| { + let mut views = views.borrow_mut(); + let mut viewers = match views.get(&StorableString(arweave_id.clone())) { + Some(list) => list.0.0, + None => Vec::new(), + }; + + if caller == Principal::anonymous() { + // Anonymous: always add None + viewers.push(None); + } else { + // Authenticated: check if already viewed + let already_viewed = viewers.iter().any(|v| match v { + Some(p) => *p == caller, + None => false, + }); + + if !already_viewed { + viewers.push(Some(caller)); + } + } + + let count = viewers.len() as u64; + views.insert( + StorableString(arweave_id), + StorableViewersList(ViewersList(viewers)), + ); + Ok(count) + }) } \ No newline at end of file diff --git a/src/dialectica/src/errors/activity.rs b/src/alex_backend/src/dialectica/errors/activity.rs similarity index 100% rename from src/dialectica/src/errors/activity.rs rename to src/alex_backend/src/dialectica/errors/activity.rs diff --git a/src/dialectica/src/errors/mod.rs b/src/alex_backend/src/dialectica/errors/mod.rs similarity index 100% rename from src/dialectica/src/errors/mod.rs rename to src/alex_backend/src/dialectica/errors/mod.rs diff --git a/src/dialectica/src/lib.rs b/src/alex_backend/src/dialectica/mod.rs similarity index 63% rename from src/dialectica/src/lib.rs rename to src/alex_backend/src/dialectica/mod.rs index ebf64407d..d32fe2198 100644 --- a/src/dialectica/src/lib.rs +++ b/src/alex_backend/src/dialectica/mod.rs @@ -1,13 +1,9 @@ -use candid::Principal; -use ic_cdk_macros::init; -use crate::store::init_counters; - pub mod api; pub mod errors; pub mod models; pub mod store; -// Re-export main types for use in export_candid!() +// Re-export main types pub use api::queries::*; pub use api::updates::*; pub use errors::activity::{ActivityError, ActivityResult}; @@ -18,10 +14,7 @@ pub use models::types::{ AddCommentRequest, AddReactionRequest, ActivityResponse, UpdateCommentRequest }; -ic_cdk::export_candid!(); - -#[init] -fn init() { - ic_cdk::setup(); - init_counters(); -} \ No newline at end of file +// Initialize dialectica counters - call this from main init +pub fn init() { + store::init_counters(); +} diff --git a/src/dialectica/src/models/activity.rs b/src/alex_backend/src/dialectica/models/activity.rs similarity index 100% rename from src/dialectica/src/models/activity.rs rename to src/alex_backend/src/dialectica/models/activity.rs diff --git a/src/dialectica/src/models/mod.rs b/src/alex_backend/src/dialectica/models/mod.rs similarity index 100% rename from src/dialectica/src/models/mod.rs rename to src/alex_backend/src/dialectica/models/mod.rs diff --git a/src/dialectica/src/models/types.rs b/src/alex_backend/src/dialectica/models/types.rs similarity index 100% rename from src/dialectica/src/models/types.rs rename to src/alex_backend/src/dialectica/models/types.rs diff --git a/src/dialectica/src/store.rs b/src/alex_backend/src/dialectica/store.rs similarity index 75% rename from src/dialectica/src/store.rs rename to src/alex_backend/src/dialectica/store.rs index 90f7ab9e7..d93120736 100644 --- a/src/dialectica/src/store.rs +++ b/src/alex_backend/src/dialectica/store.rs @@ -5,16 +5,18 @@ use std::borrow::Cow; use std::cell::RefCell; use serde::{Serialize, Deserialize}; -use crate::models::activity::Activity; +use super::models::activity::Activity; type Memory = VirtualMemory; -// Memory IDs for different maps -const ACTIVITIES_MEM_ID: MemoryId = MemoryId::new(0); -const USER_ACTIVITIES_MEM_ID: MemoryId = MemoryId::new(1); -const ARWEAVE_ACTIVITIES_MEM_ID: MemoryId = MemoryId::new(2); -const ACTIVITY_COUNTER_MEM_ID: MemoryId = MemoryId::new(3); -const USER_REACTIONS_MEM_ID: MemoryId = MemoryId::new(4); +// Memory IDs for different maps (starting from 20 to avoid conflicts with alex_backend) +const ACTIVITIES_MEM_ID: MemoryId = MemoryId::new(20); +const USER_ACTIVITIES_MEM_ID: MemoryId = MemoryId::new(21); +const ARWEAVE_ACTIVITIES_MEM_ID: MemoryId = MemoryId::new(22); +const ACTIVITY_COUNTER_MEM_ID: MemoryId = MemoryId::new(23); +const USER_REACTIONS_MEM_ID: MemoryId = MemoryId::new(24); +const IMPRESSIONS_MEM_ID: MemoryId = MemoryId::new(25); +const VIEWS_MEM_ID: MemoryId = MemoryId::new(26); const MAX_VALUE_SIZE: u32 = 65536; // 64KB @@ -61,6 +63,28 @@ pub struct ActivityIdList(pub Vec); #[derive(Debug, Clone)] pub struct StorableActivityIdList(pub ActivityIdList); +// Viewers list: Vec> where Some = authenticated, None = anonymous +#[derive(CandidType, Serialize, Deserialize, Clone, Debug)] +pub struct ViewersList(pub Vec>); + +#[derive(Debug, Clone)] +pub struct StorableViewersList(pub ViewersList); + +impl Storable for StorableViewersList { + fn to_bytes(&self) -> Cow<[u8]> { + Cow::Owned(Encode!(&self.0).unwrap()) + } + + fn from_bytes(bytes: Cow<[u8]>) -> Self { + Self(Decode!(bytes.as_ref(), ViewersList).unwrap()) + } + + const BOUND: ic_stable_structures::storable::Bound = ic_stable_structures::storable::Bound::Bounded { + max_size: MAX_VALUE_SIZE, + is_fixed_size: false, + }; +} + impl Storable for StorableActivityIdList { fn to_bytes(&self) -> Cow<[u8]> { Cow::Owned(Encode!(&self.0).unwrap()) @@ -158,6 +182,20 @@ thread_local! { MEMORY_MANAGER.with(|m| m.borrow().get(USER_REACTIONS_MEM_ID)), ) ); + + // Impressions: arweave_id -> count (simple counter, no dedup) + pub static IMPRESSIONS: RefCell> = RefCell::new( + StableBTreeMap::init( + MEMORY_MANAGER.with(|m| m.borrow().get(IMPRESSIONS_MEM_ID)), + ) + ); + + // Views: arweave_id -> list of viewers (deduplicated for authenticated users) + pub static VIEWS: RefCell> = RefCell::new( + StableBTreeMap::init( + MEMORY_MANAGER.with(|m| m.borrow().get(VIEWS_MEM_ID)), + ) + ); } /// Initialize the counter if it doesn't exist diff --git a/src/alex_backend/src/lib.rs b/src/alex_backend/src/lib.rs index 9533ff295..214e53fbd 100644 --- a/src/alex_backend/src/lib.rs +++ b/src/alex_backend/src/lib.rs @@ -8,6 +8,16 @@ use std::borrow::Cow; mod nft_users; pub use nft_users::{UserNFTInfo, get_stored_nft_users}; +// Dialectica module - social features (reactions, comments, views, impressions) +pub mod dialectica; + +// Re-export dialectica types for candid export +pub use dialectica::{ + Activity, ActivityType, ActivityError, ActivityResult, + ReactionType, ReactionCounts, CommentInfo, + AddCommentRequest, AddReactionRequest, ActivityResponse, UpdateCommentRequest, +}; + 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"; @@ -226,6 +236,19 @@ pub fn start_alex_supply_timer() -> Result { Ok("ALEX token supply timer started. Will update every 24 hours.".to_string()) } +#[ic_cdk::init] +fn init() { + ic_cdk::setup(); + dialectica::init(); + nft_users::setup_timer(); +} + +#[ic_cdk::post_upgrade] +fn post_upgrade() { + dialectica::init(); + nft_users::setup_timer(); +} + ic_cdk::export_candid!(); diff --git a/src/alex_backend/src/nft_users.rs b/src/alex_backend/src/nft_users.rs index 832ece566..9fd0b7c9b 100644 --- a/src/alex_backend/src/nft_users.rs +++ b/src/alex_backend/src/nft_users.rs @@ -112,7 +112,7 @@ async fn update_nft_users() -> Result, String> { Ok(updated_users) } -fn setup_timer() { +pub fn setup_timer() { // Set up timer to update NFT users every 4 hours let timer_duration_nanos: u64 = 4 * 60 * 60 * 1_000_000_000; // 4 hours in nanoseconds ic_cdk_timers::set_timer_interval(std::time::Duration::from_nanos(timer_duration_nanos), || { @@ -125,15 +125,6 @@ fn setup_timer() { }); } -#[ic_cdk::init] -fn init() { - setup_timer(); -} - -#[ic_cdk::post_upgrade] -fn post_upgrade() { - setup_timer(); -} #[ic_cdk::query] pub fn get_stored_nft_users() -> Vec { diff --git a/src/alex_frontend/core/components/Comment/list.tsx b/src/alex_frontend/core/components/Comment/list.tsx index f07576590..29f1cbda8 100644 --- a/src/alex_frontend/core/components/Comment/list.tsx +++ b/src/alex_frontend/core/components/Comment/list.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from "react"; import { Card, CardContent } from "@/lib/components/card"; -import { useDialectica } from "@/hooks/actors"; +import { useAlexBackend } from "@/hooks/actors"; import { Principal } from "@dfinity/principal"; import { convertTimestamp } from "@/utils/general"; import UsernameBadge from "@/components/UsernameBadge"; @@ -29,7 +29,7 @@ const CommentList: React.FC = ({ }) => { const [comments, setComments] = useState([]); const [loading, setLoading] = useState(true); - const { actor } = useDialectica(); + const { actor } = useAlexBackend(); const fetchComments = async () => { if (!actor) return; diff --git a/src/alex_frontend/core/components/Comment/post.tsx b/src/alex_frontend/core/components/Comment/post.tsx index 3fcdd6bef..8b4626035 100644 --- a/src/alex_frontend/core/components/Comment/post.tsx +++ b/src/alex_frontend/core/components/Comment/post.tsx @@ -2,7 +2,7 @@ import React, { useState } from "react"; import { Button } from "@/lib/components/button"; import { Textarea } from "@/lib/components/textarea"; import { Send } from "lucide-react"; -import { useDialectica } from "@/hooks/actors"; +import { useAlexBackend } from "@/hooks/actors"; import { useAppSelector } from "@/store/hooks/useAppSelector"; import { toast } from "sonner"; @@ -21,7 +21,7 @@ const AddComment: React.FC = ({ }) => { const [comment, setComment] = useState(""); const [submitting, setSubmitting] = useState(false); - const { actor } = useDialectica(); + const { actor } = useAlexBackend(); const { user } = useAppSelector((state) => state.auth); const handleAddComment = async () => { diff --git a/src/alex_frontend/core/features/balance/alex/Locked.tsx b/src/alex_frontend/core/features/balance/alex/Locked.tsx index 8e549ab1d..ab0dd9ebe 100644 --- a/src/alex_frontend/core/features/balance/alex/Locked.tsx +++ b/src/alex_frontend/core/features/balance/alex/Locked.tsx @@ -21,7 +21,7 @@ const AlexLockedBalance: React.FC = ({ menu }) => { if (menu) { return ( - + ALEX diff --git a/src/alex_frontend/core/features/balance/alex/Unlocked.tsx b/src/alex_frontend/core/features/balance/alex/Unlocked.tsx index 2d7b64975..841b45bdb 100644 --- a/src/alex_frontend/core/features/balance/alex/Unlocked.tsx +++ b/src/alex_frontend/core/features/balance/alex/Unlocked.tsx @@ -24,7 +24,7 @@ const AlexUnlockedBalance: React.FC = ({ menu }) => { if (menu) { return ( - + ALEX diff --git a/src/alex_frontend/core/features/balance/icp/index.tsx b/src/alex_frontend/core/features/balance/icp/index.tsx index a4ea47d95..fc9a02610 100644 --- a/src/alex_frontend/core/features/balance/icp/index.tsx +++ b/src/alex_frontend/core/features/balance/icp/index.tsx @@ -24,7 +24,7 @@ const IcpBalance: React.FC = ({ menu }) => { if (menu) { return ( - + ICP diff --git a/src/alex_frontend/core/features/balance/lbry/Locked.tsx b/src/alex_frontend/core/features/balance/lbry/Locked.tsx index 1127f68c5..f6231ae54 100644 --- a/src/alex_frontend/core/features/balance/lbry/Locked.tsx +++ b/src/alex_frontend/core/features/balance/lbry/Locked.tsx @@ -29,7 +29,7 @@ const LbryLockedBalance: React.FC = ({ menu }) => { if (menu) { return ( - + LBRY diff --git a/src/alex_frontend/core/features/balance/lbry/Unlocked.tsx b/src/alex_frontend/core/features/balance/lbry/Unlocked.tsx index 1f9de05d1..fdf5d8341 100644 --- a/src/alex_frontend/core/features/balance/lbry/Unlocked.tsx +++ b/src/alex_frontend/core/features/balance/lbry/Unlocked.tsx @@ -23,7 +23,7 @@ const LbryUnlockedBalance: React.FC = ({ menu }) => { if (menu) { return ( - + LBRY @@ -50,7 +50,7 @@ const LbryUnlockedBalance: React.FC = ({ menu }) => { navigate({ to: '/exchange' })} + onClick={() => navigate({ to: '/swap' })} /> diff --git a/src/alex_frontend/core/features/balance/usd/index.tsx b/src/alex_frontend/core/features/balance/usd/index.tsx index e741fb3cb..b71b24f31 100644 --- a/src/alex_frontend/core/features/balance/usd/index.tsx +++ b/src/alex_frontend/core/features/balance/usd/index.tsx @@ -30,7 +30,7 @@ const UsdBalance: React.FC = ({ menu }) => { if (menu) { return ( - + USD diff --git a/src/alex_frontend/core/features/mines/components/BalanceDisplay.tsx b/src/alex_frontend/core/features/mines/components/BalanceDisplay.tsx deleted file mode 100644 index b5e8c131a..000000000 --- a/src/alex_frontend/core/features/mines/components/BalanceDisplay.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import React from "react"; -import { Wallet, RefreshCw } from "lucide-react"; -import { Button } from "@/lib/components/button"; - -interface BalanceDisplayProps { - lockedBalance: number; - loading: boolean; - onRefresh: () => void; -} - -const BalanceDisplay: React.FC = ({ - lockedBalance, - loading, - onRefresh, -}) => { - const formatBalance = (balance: number): string => { - if (balance < 0) return "—"; - return balance.toFixed(2); - }; - - return ( -
- {/* Header with refresh */} -
-
- - Spending Balance -
- -
- - {/* Balance Display */} -
- {loading ? ( -
- ) : ( - <> - - {formatBalance(lockedBalance)} - - LBRY - - )} -
- - {/* Helper text */} - {lockedBalance >= 0 && lockedBalance < 1 && !loading && ( -

- Top up your spending balance in the Exchange to play -

- )} -
- ); -}; - -export default BalanceDisplay; diff --git a/src/alex_frontend/core/features/mines/components/BetControls.tsx b/src/alex_frontend/core/features/mines/components/BetControls.tsx deleted file mode 100644 index 9735fdeba..000000000 --- a/src/alex_frontend/core/features/mines/components/BetControls.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import React from "react"; -import { MIN_MINES, MAX_MINES, MIN_BET, MAX_BET } from "../types"; -import { Label } from "@/lib/components/label"; -import { Input } from "@/lib/components/input"; -import { Slider } from "@/lib/components/slider"; -import { Button } from "@/lib/components/button"; -import { Bomb, Coins } from "lucide-react"; - -interface BetControlsProps { - betAmount: string; - mineCount: number; - onBetAmountChange: (value: string) => void; - onMineCountChange: (value: number) => void; - disabled: boolean; -} - -const BetControls: React.FC = ({ - betAmount, - mineCount, - onBetAmountChange, - onMineCountChange, - disabled, -}) => { - const handleHalf = () => { - const current = parseFloat(betAmount) || 0; - const newValue = Math.max(MIN_BET, current / 2); - onBetAmountChange(newValue.toString()); - }; - - const handleDouble = () => { - const current = parseFloat(betAmount) || 0; - const newValue = Math.min(MAX_BET, current * 2); - onBetAmountChange(newValue.toString()); - }; - - const handleMin = () => { - onBetAmountChange(MIN_BET.toString()); - }; - - const handleMax = () => { - onBetAmountChange(MAX_BET.toString()); - }; - - return ( -
- {/* Bet Amount */} -
-
- - - LBRY -
-
- onBetAmountChange(e.target.value)} - min={MIN_BET} - max={MAX_BET} - step="0.01" - disabled={disabled} - className="font-mono text-lg h-12 pr-4 bg-background border-border focus:border-emerald-500 focus:ring-emerald-500/20" - /> -
-
- - - - -
-
- - {/* Divider */} -
- - {/* Mine Count */} -
-
-
- - -
-
- {mineCount} - / 15 -
-
- onMineCountChange(values[0])} - min={MIN_MINES} - max={MAX_MINES} - step={1} - disabled={disabled} - className="py-2" - /> -
- Low Risk - High Risk -
-
- - {/* Risk Indicator */} -
-
- Risk Level: - - {mineCount <= 3 ? "Low" : - mineCount <= 7 ? "Medium" : - mineCount <= 11 ? "High" : - "Extreme"} - -
-
- Safe Tiles: - {16 - mineCount} -
-
-
- ); -}; - -export default BetControls; diff --git a/src/alex_frontend/core/features/mines/components/GameControls.tsx b/src/alex_frontend/core/features/mines/components/GameControls.tsx deleted file mode 100644 index 4b9679d69..000000000 --- a/src/alex_frontend/core/features/mines/components/GameControls.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import React from "react"; -import { Button } from "@/lib/components/button"; -import { Loader2, RotateCcw, Play, Wallet } from "lucide-react"; - -interface GameControlsProps { - hasActiveGame: boolean; - isGameEnded: boolean; - canCashOut: boolean; - isStarting: boolean; - isCashingOut: boolean; - onStartGame: () => void; - onCashOut: () => void; - onNewGame: () => void; - potentialWin: string; - multiplier: number; -} - -const GameControls: React.FC = ({ - hasActiveGame, - isGameEnded, - canCashOut, - isStarting, - isCashingOut, - onStartGame, - onCashOut, - onNewGame, - potentialWin, - multiplier, -}) => { - // Show Start Game button when no active game - if (!hasActiveGame) { - return ( - - ); - } - - // Show New Game button when game has ended - if (isGameEnded) { - return ( - - ); - } - - // Show Cash Out button during active game - return ( - - ); -}; - -export default GameControls; diff --git a/src/alex_frontend/core/features/mines/components/GameResult.tsx b/src/alex_frontend/core/features/mines/components/GameResult.tsx deleted file mode 100644 index 18d7555ca..000000000 --- a/src/alex_frontend/core/features/mines/components/GameResult.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import React from "react"; -import type { SerializableGame } from "../types"; -import { formatLbry } from "../types"; -import { cn } from "@/lib/utils"; -import { Trophy, Skull, Copy, Check, X } from "lucide-react"; -import { Button } from "@/lib/components/button"; - -interface GameResultProps { - game: SerializableGame; - onNewGame: () => void; - onClose: () => void; -} - -const GameResult: React.FC = ({ game, onNewGame, onClose }) => { - const [copied, setCopied] = React.useState(false); - const isWin = "Won" in game.status; - const serverSeed = game.server_seed.length > 0 ? game.server_seed[0] : null; - - const handleCopySeeds = () => { - const seedInfo = `Server Seed: ${serverSeed}\nServer Seed Hash: ${game.server_seed_hash}\nClient Seed: ${game.client_seed}`; - navigator.clipboard.writeText(seedInfo); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; - - return ( -
-
e.stopPropagation()} - > - {/* Close Button */} - - - {/* Icon */} -
- {isWin ? ( - - ) : ( - - )} -
- - {/* Title */} -

- {isWin ? "You Won!" : "Game Over"} -

- - {/* Stats */} -
-
- Bet Amount: - {formatLbry(game.bet_amount)} LBRY -
- {isWin && ( - <> -
- Multiplier: - {game.current_multiplier.toFixed(2)}× -
-
- Won: - {formatLbry(game.potential_win)} LBRY -
- - )} -
- Tiles Revealed: - {game.revealed_count} -
-
- - {/* Provably Fair */} - {serverSeed && ( -
-
- Provably Fair - -
-
-
Server: {serverSeed.slice(0, 20)}...
-
Hash: {game.server_seed_hash.slice(0, 20)}...
-
-
- )} - - {/* New Game Button */} - -
-
- ); -}; - -export default GameResult; diff --git a/src/alex_frontend/core/features/mines/components/MinesGrid.tsx b/src/alex_frontend/core/features/mines/components/MinesGrid.tsx deleted file mode 100644 index 54cd97de7..000000000 --- a/src/alex_frontend/core/features/mines/components/MinesGrid.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React from "react"; -import type { SerializableGame } from "../types"; -import MinesTile from "./MinesTile"; - -interface MinesGridProps { - game: SerializableGame; - onTileClick: (index: number) => void; - disabled: boolean; - loadingTileIndex?: number | null; -} - -const MinesGrid: React.FC = ({ game, onTileClick, disabled, loadingTileIndex }) => { - const isGameActive = "Active" in game.status; - - return ( -
- {game.tiles.map((tile) => ( - onTileClick(tile.index)} - disabled={disabled} - isClickable={isGameActive} - isLoading={loadingTileIndex === tile.index} - /> - ))} -
- ); -}; - -export default MinesGrid; diff --git a/src/alex_frontend/core/features/mines/components/MinesTile.tsx b/src/alex_frontend/core/features/mines/components/MinesTile.tsx deleted file mode 100644 index 66dad41fc..000000000 --- a/src/alex_frontend/core/features/mines/components/MinesTile.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import React from "react"; -import type { SerializableTile } from "../types"; -import { cn } from "@/lib/utils"; -import { Bomb, Gem } from "lucide-react"; - -interface MinesTileProps { - tile: SerializableTile; - onClick: () => void; - disabled: boolean; - isClickable: boolean; - isLoading?: boolean; -} - -const MinesTile: React.FC = ({ - tile, - onClick, - disabled, - isClickable, - isLoading = false, -}) => { - const isRevealed = "Revealed" in tile.state; - const isMine = "Mine" in tile.state; - const isHidden = "Hidden" in tile.state; - - const getBackgroundClass = () => { - if (isLoading) return "border-emerald-500 bg-emerald-500/20"; - if (isRevealed) return "bg-emerald-500/20 border-emerald-500 shadow-lg shadow-emerald-500/20"; - if (isMine) return "bg-red-500/20 border-red-500 shadow-lg shadow-red-500/30"; - return "bg-muted border-border hover:border-muted-foreground hover:bg-muted/80"; - }; - - const renderContent = () => { - if (isLoading) { - return ( -
-
-
-
-
- ); - } - if (isRevealed) { - return ( - - ); - } - if (isMine) { - return ( - - ); - } - return null; - }; - - return ( - - ); -}; - -export default MinesTile; diff --git a/src/alex_frontend/core/features/mines/components/MultiplierDisplay.tsx b/src/alex_frontend/core/features/mines/components/MultiplierDisplay.tsx deleted file mode 100644 index 852aa3710..000000000 --- a/src/alex_frontend/core/features/mines/components/MultiplierDisplay.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import React from "react"; -import { cn } from "@/lib/utils"; - -interface MultiplierDisplayProps { - currentMultiplier: number; - revealedCount: number; - mineCount: number; - multiplierTable: number[]; -} - -const MultiplierDisplay: React.FC = ({ - currentMultiplier, - revealedCount, - mineCount, - multiplierTable, -}) => { - const safeClicks = 16 - mineCount; - - return ( -
- {/* Current Multiplier */} -
-
Current Multiplier
-
- {currentMultiplier.toFixed(2)}× -
-
- - {/* Multiplier Progression */} -
-
Multiplier Progression
-
- {multiplierTable.slice(0, 10).map((mult, index) => ( -
-
{mult.toFixed(2)}×
-
- ))} -
- {multiplierTable.length > 10 && ( -
- +{multiplierTable.length - 10} more... -
- )} -
- - {/* Progress */} -
- {revealedCount} / {safeClicks} safe tiles revealed -
-
- ); -}; - -export default MultiplierDisplay; diff --git a/src/alex_frontend/core/features/mines/components/index.ts b/src/alex_frontend/core/features/mines/components/index.ts deleted file mode 100644 index edad6c42a..000000000 --- a/src/alex_frontend/core/features/mines/components/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { default as MinesTile } from "./MinesTile"; -export { default as MinesGrid } from "./MinesGrid"; -export { default as BetControls } from "./BetControls"; -export { default as GameControls } from "./GameControls"; -export { default as MultiplierDisplay } from "./MultiplierDisplay"; -export { default as BalanceDisplay } from "./BalanceDisplay"; -export { default as GameResult } from "./GameResult"; diff --git a/src/alex_frontend/core/features/mines/index.ts b/src/alex_frontend/core/features/mines/index.ts deleted file mode 100644 index ebc5e7a26..000000000 --- a/src/alex_frontend/core/features/mines/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Main feature export -export * from "./components"; -export * from "./types"; -export { default as minesReducer } from "./minesSlice"; -export * from "./minesSlice"; -export * from "./thunks"; diff --git a/src/alex_frontend/core/features/mines/minesSlice.ts b/src/alex_frontend/core/features/mines/minesSlice.ts deleted file mode 100644 index 0e537bf81..000000000 --- a/src/alex_frontend/core/features/mines/minesSlice.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { createSlice, PayloadAction } from "@reduxjs/toolkit"; -import type { - MinesState, - SerializableGame, - SerializableGameSummary, - SerializableClickResult, - SerializableCashoutResult, -} from "./types"; - -const initialState: MinesState = { - activeGame: null, - gameLoading: false, - gameHistory: [], - historyLoading: false, - selectedMineCount: 3, - betAmount: "1", - isStartingGame: false, - isClickingTile: false, - clickingTileIndex: null, - isCashingOut: false, - multiplierTable: [], - error: null, - isReady: false, -}; - -const minesSlice = createSlice({ - name: "mines", - initialState, - reducers: { - setActiveGame: (state, action: PayloadAction) => { - state.activeGame = action.payload; - }, - setGameLoading: (state, action: PayloadAction) => { - state.gameLoading = action.payload; - }, - updateGameAfterClick: (state, action: PayloadAction) => { - if (state.activeGame) { - const result = action.payload; - state.activeGame.current_multiplier = result.new_multiplier; - state.activeGame.potential_win = result.potential_win; - state.activeGame.revealed_count = result.revealed_count; - - const tile = state.activeGame.tiles[result.tile_index]; - if (tile) { - tile.state = result.is_mine ? { Mine: null } : { Revealed: null }; - } - - if ("Lost" in result.game_status) { - state.activeGame.status = { Lost: null }; - if (result.server_seed.length > 0) { - state.activeGame.server_seed = result.server_seed; - } - if (result.mine_positions.length > 0) { - const mineData = result.mine_positions[0]; - if (mineData) { - mineData.forEach((pos) => { - if (state.activeGame?.tiles[pos]) { - state.activeGame.tiles[pos].state = { Mine: null }; - } - }); - } - } - } - } - }, - gameEnded: (state, action: PayloadAction) => { - if (action.payload && state.activeGame) { - state.activeGame.status = { Won: null }; - state.activeGame.server_seed = [action.payload.server_seed]; - } - }, - clearActiveGame: (state) => { - state.activeGame = null; - }, - setGameHistory: (state, action: PayloadAction) => { - state.gameHistory = action.payload; - }, - setHistoryLoading: (state, action: PayloadAction) => { - state.historyLoading = action.payload; - }, - setSelectedMineCount: (state, action: PayloadAction) => { - state.selectedMineCount = action.payload; - }, - setBetAmount: (state, action: PayloadAction) => { - state.betAmount = action.payload; - }, - setIsStartingGame: (state, action: PayloadAction) => { - state.isStartingGame = action.payload; - }, - setIsClickingTile: (state, action: PayloadAction) => { - state.isClickingTile = action.payload; - if (!action.payload) { - state.clickingTileIndex = null; - } - }, - setClickingTileIndex: (state, action: PayloadAction) => { - state.clickingTileIndex = action.payload; - }, - setIsCashingOut: (state, action: PayloadAction) => { - state.isCashingOut = action.payload; - }, - setMultiplierTable: (state, action: PayloadAction) => { - state.multiplierTable = action.payload; - }, - setError: (state, action: PayloadAction) => { - state.error = action.payload; - }, - clearError: (state) => { - state.error = null; - }, - setIsReady: (state, action: PayloadAction) => { - state.isReady = action.payload; - }, - resetState: () => initialState, - }, -}); - -export const { - setActiveGame, - setGameLoading, - updateGameAfterClick, - gameEnded, - clearActiveGame, - setGameHistory, - setHistoryLoading, - setSelectedMineCount, - setBetAmount, - setIsStartingGame, - setIsClickingTile, - setClickingTileIndex, - setIsCashingOut, - setMultiplierTable, - setError, - clearError, - setIsReady, - resetState, -} = minesSlice.actions; - -export default minesSlice.reducer; diff --git a/src/alex_frontend/core/features/mines/thunks/cashOut.ts b/src/alex_frontend/core/features/mines/thunks/cashOut.ts deleted file mode 100644 index e89a652f0..000000000 --- a/src/alex_frontend/core/features/mines/thunks/cashOut.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { createAsyncThunk } from "@reduxjs/toolkit"; -import type { KairosService } from "../types"; -import { serializeCashoutResult } from "../types"; -import { - setIsCashingOut, - gameEnded, - setError, -} from "../minesSlice"; - -interface CashOutParams { - gameId: string; // String ID from serialized game - actor: KairosService; -} - -export const cashOut = createAsyncThunk( - "mines/cashOut", - async ({ gameId, actor }: CashOutParams, { dispatch }) => { - dispatch(setIsCashingOut(true)); - dispatch(setError(null)); - - try { - // Convert string ID back to bigint for canister call - const result = await actor.cash_out(BigInt(gameId)); - - if ("Err" in result) { - const error = result.Err; - const errorMessage = - typeof error === "object" - ? Object.keys(error)[0] - : String(error); - throw new Error(errorMessage); - } - - const cashoutResult = result.Ok; - const serializedResult = serializeCashoutResult(cashoutResult); - dispatch(gameEnded(serializedResult)); - - // Game result stays visible until user manually starts a new game - - return serializedResult; - } catch (error: any) { - dispatch(setError(error.message || "Failed to cash out")); - throw error; - } finally { - dispatch(setIsCashingOut(false)); - } - } -); diff --git a/src/alex_frontend/core/features/mines/thunks/clickTile.ts b/src/alex_frontend/core/features/mines/thunks/clickTile.ts deleted file mode 100644 index 26316a190..000000000 --- a/src/alex_frontend/core/features/mines/thunks/clickTile.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { createAsyncThunk } from "@reduxjs/toolkit"; -import type { KairosService } from "../types"; -import { serializeClickResult } from "../types"; -import { - setIsClickingTile, - setClickingTileIndex, - updateGameAfterClick, - setError, -} from "../minesSlice"; - -interface ClickTileParams { - gameId: string; // String ID from serialized game - tileIndex: number; - actor: KairosService; -} - -export const clickTile = createAsyncThunk( - "mines/clickTile", - async ({ gameId, tileIndex, actor }: ClickTileParams, { dispatch }) => { - dispatch(setIsClickingTile(true)); - dispatch(setClickingTileIndex(tileIndex)); - dispatch(setError(null)); - - try { - // Convert string ID back to bigint for canister call - const result = await actor.click_tile(BigInt(gameId), tileIndex); - - if ("Err" in result) { - const error = result.Err; - const errorMessage = - typeof error === "object" - ? Object.keys(error)[0] - : String(error); - throw new Error(errorMessage); - } - - const clickResult = result.Ok; - const serializedResult = serializeClickResult(clickResult); - dispatch(updateGameAfterClick(serializedResult)); - - // Game result stays visible until user manually starts a new game - - return serializedResult; - } catch (error: any) { - dispatch(setError(error.message || "Failed to click tile")); - throw error; - } finally { - dispatch(setIsClickingTile(false)); - } - } -); diff --git a/src/alex_frontend/core/features/mines/thunks/fetchActiveGame.ts b/src/alex_frontend/core/features/mines/thunks/fetchActiveGame.ts deleted file mode 100644 index e259de293..000000000 --- a/src/alex_frontend/core/features/mines/thunks/fetchActiveGame.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { createAsyncThunk } from "@reduxjs/toolkit"; -import type { KairosService } from "../types"; -import { serializeGame } from "../types"; -import { setActiveGame, setGameLoading, setError } from "../minesSlice"; - -interface FetchActiveGameParams { - actor: KairosService; -} - -export const fetchActiveGame = createAsyncThunk( - "mines/fetchActiveGame", - async ({ actor }: FetchActiveGameParams, { dispatch }) => { - dispatch(setGameLoading(true)); - - try { - const result = await actor.get_active_game(); - // Result is Option, so it's an array with 0 or 1 element - if (result && result.length > 0 && result[0]) { - const serializedGame = serializeGame(result[0]); - dispatch(setActiveGame(serializedGame)); - return serializedGame; - } else { - dispatch(setActiveGame(null)); - return null; - } - } catch (error: any) { - dispatch(setError(error.message || "Failed to fetch active game")); - throw error; - } finally { - dispatch(setGameLoading(false)); - } - } -); diff --git a/src/alex_frontend/core/features/mines/thunks/index.ts b/src/alex_frontend/core/features/mines/thunks/index.ts deleted file mode 100644 index e5f96f7bd..000000000 --- a/src/alex_frontend/core/features/mines/thunks/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { startGame } from "./startGame"; -export { clickTile } from "./clickTile"; -export { cashOut } from "./cashOut"; -export { fetchActiveGame } from "./fetchActiveGame"; diff --git a/src/alex_frontend/core/features/mines/thunks/startGame.ts b/src/alex_frontend/core/features/mines/thunks/startGame.ts deleted file mode 100644 index db1e91ee0..000000000 --- a/src/alex_frontend/core/features/mines/thunks/startGame.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { createAsyncThunk } from "@reduxjs/toolkit"; -import { Principal } from "@dfinity/principal"; -import { lbryToE8s, serializeGame } from "../types"; -import type { KairosService, NftManagerService } from "../types"; -import { - setIsStartingGame, - setActiveGame, - setError, -} from "../minesSlice"; -import { RootState } from "@/store"; - -// Generate a random client seed -const generateClientSeed = (): string => { - const array = new Uint8Array(16); - crypto.getRandomValues(array); - return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join( - "" - ); -}; - -interface StartGameParams { - betAmount: number; - mineCount: number; - actor: KairosService; - nftManagerActor: NftManagerService; -} - -export const startGame = createAsyncThunk< - { game_id: string; server_seed_hash: string } | null, - StartGameParams, - { state: RootState } ->( - "mines/startGame", - async ({ betAmount, mineCount, actor, nftManagerActor }: StartGameParams, { dispatch }) => { - dispatch(setIsStartingGame(true)); - dispatch(setError(null)); - - try { - // Get Kairos canister ID from environment - const kairosCanisterId = process.env.CANISTER_ID_KAIROS; - if (!kairosCanisterId) { - throw new Error("Kairos canister ID not found in environment"); - } - - const betAmountE8s = lbryToE8s(betAmount); - - // Step 1: Transfer LBRY from user's locked balance (NFT Manager) to Kairos - // This calls NFT Manager's spend_for_app function - const spendResult = await nftManagerActor.spend_for_app( - Principal.fromText(kairosCanisterId), - betAmountE8s - ); - - if ("Err" in spendResult) { - const errorMessage = spendResult.Err; - throw new Error(`Failed to transfer LBRY: ${errorMessage}`); - } - - // Step 2: Now start the game in Kairos - const clientSeed = generateClientSeed(); - - const config = { - bet_amount: betAmountE8s, - mine_count: mineCount, - client_seed: clientSeed, - }; - - const result = await actor.start_game(config); - - if ("Err" in result) { - const error = result.Err; - const errorMessage = - typeof error === "object" - ? Object.keys(error)[0] - : String(error); - throw new Error(errorMessage); - } - - // Fetch the active game to get full state - const gameResult = await actor.get_active_game(); - if (gameResult && gameResult.length > 0 && gameResult[0]) { - dispatch(setActiveGame(serializeGame(gameResult[0]))); - } - - // Return serializable data only (convert BigInt to string) - const response = result.Ok; - return { - game_id: response.game_id.toString(), - server_seed_hash: response.server_seed_hash, - }; - } catch (error: any) { - dispatch(setError(error.message || "Failed to start game")); - throw error; - } finally { - dispatch(setIsStartingGame(false)); - } - } -); diff --git a/src/alex_frontend/core/features/mines/types.ts b/src/alex_frontend/core/features/mines/types.ts deleted file mode 100644 index 5a674ca22..000000000 --- a/src/alex_frontend/core/features/mines/types.ts +++ /dev/null @@ -1,200 +0,0 @@ -// Re-export all types from declarations -export type { - CashoutResult, - ClickResult, - Game, - GameConfig, - GameStatus, - GameSummary, - StartGameResponse, - Tile, - TileState, - _SERVICE as KairosService, -} from "../../../../declarations/kairos/kairos.did"; - -export type { _SERVICE as NftManagerService } from "../../../../declarations/nft_manager/nft_manager.did"; - -// Frontend-specific types -import type { - Game, - GameSummary, - GameStatus, - TileState, - ClickResult, - CashoutResult, -} from "../../../../declarations/kairos/kairos.did"; - -// Serializable versions of types (BigInt converted to string for Redux) -export interface SerializableTile { - is_mine: boolean; - state: TileState; - index: number; -} - -export interface SerializableGame { - id: string; // BigInt -> string - bet_amount: string; // BigInt -> string (e8s) - status: GameStatus; - server_seed: string[]; - tiles: SerializableTile[]; - potential_win: string; // BigInt -> string (e8s) - player: string; // Principal -> string - client_seed: string; - revealed_count: number; - current_multiplier: number; - created_at: string; // BigInt -> string - server_seed_hash: string; - mine_count: number; - ended_at: string[]; // BigInt -> string -} - -export interface SerializableClickResult { - is_mine: boolean; - server_seed: string[]; - potential_win: string; // BigInt -> string (e8s) - revealed_count: number; - game_id: string; - game_status: GameStatus; - new_multiplier: number; - mine_positions: number[][]; - tile_index: number; -} - -export interface SerializableCashoutResult { - bet_amount: string; // BigInt -> string (e8s) - server_seed: string; - final_multiplier: number; - revealed_count: number; - game_id: string; - win_amount: string; // BigInt -> string (e8s) - mine_positions: number[]; -} - -export interface SerializableGameSummary { - id: string; - bet_amount: string; // BigInt -> string (e8s) - status: GameStatus; - final_multiplier: number; - revealed_count: number; - created_at: string; - mine_count: number; - win_amount: string; // BigInt -> string (e8s) - ended_at: string[]; -} - -// Converter functions -export const serializeGame = (game: Game): SerializableGame => ({ - id: game.id.toString(), - bet_amount: game.bet_amount.toString(), - status: game.status, - server_seed: game.server_seed.length > 0 ? [game.server_seed[0]!] : [], - tiles: game.tiles.map(tile => ({ - is_mine: tile.is_mine, - state: tile.state, - index: tile.index, - })), - potential_win: game.potential_win.toString(), - player: game.player.toString(), - client_seed: game.client_seed, - revealed_count: game.revealed_count, - current_multiplier: game.current_multiplier, - created_at: game.created_at.toString(), - server_seed_hash: game.server_seed_hash, - mine_count: game.mine_count, - ended_at: game.ended_at.length > 0 ? [game.ended_at[0]!.toString()] : [], -}); - -export const serializeClickResult = (result: ClickResult): SerializableClickResult => ({ - is_mine: result.is_mine, - server_seed: result.server_seed.length > 0 ? [result.server_seed[0]!] : [], - potential_win: result.potential_win.toString(), - revealed_count: result.revealed_count, - game_id: result.game_id.toString(), - game_status: result.game_status, - new_multiplier: result.new_multiplier, - mine_positions: result.mine_positions.length > 0 - ? [Array.from(result.mine_positions[0] as Uint8Array | number[])] - : [], - tile_index: result.tile_index, -}); - -export const serializeCashoutResult = (result: CashoutResult): SerializableCashoutResult => ({ - bet_amount: result.bet_amount.toString(), - server_seed: result.server_seed, - final_multiplier: result.final_multiplier, - revealed_count: result.revealed_count, - game_id: result.game_id.toString(), - win_amount: result.win_amount.toString(), - mine_positions: Array.from(result.mine_positions as Uint8Array | number[]), -}); - -export const serializeGameSummary = (summary: GameSummary): SerializableGameSummary => ({ - id: summary.id.toString(), - bet_amount: summary.bet_amount.toString(), - status: summary.status, - final_multiplier: summary.final_multiplier, - revealed_count: summary.revealed_count, - created_at: summary.created_at.toString(), - mine_count: summary.mine_count, - win_amount: summary.win_amount.toString(), - ended_at: summary.ended_at.length > 0 ? [summary.ended_at[0]!.toString()] : [], -}); - -export interface MinesState { - // Active game (serializable) - activeGame: SerializableGame | null; - gameLoading: boolean; - - // Game history (serializable) - gameHistory: SerializableGameSummary[]; - historyLoading: boolean; - - // UI state - selectedMineCount: number; - betAmount: string; - isStartingGame: boolean; - isClickingTile: boolean; - clickingTileIndex: number | null; - isCashingOut: boolean; - - // Multiplier preview - multiplierTable: number[]; - - // Errors - error: string | null; - - // Canister ready state - isReady: boolean; -} - -// Constants -export const MIN_MINES = 1; -export const MAX_MINES = 15; -export const GRID_SIZE = 16; -export const MIN_BET = 0.01; -export const MAX_BET = 1000; -export const TOKEN_DECIMALS = 8; - -// Helpers -export const lbryToE8s = (lbry: number): bigint => { - return BigInt(Math.floor(lbry * 10 ** TOKEN_DECIMALS)); -}; - -export const e8sToLbry = (e8s: bigint | string): number => { - const value = typeof e8s === "string" ? BigInt(e8s) : e8s; - return Number(value) / 10 ** TOKEN_DECIMALS; -}; - -export const formatLbry = (e8s: bigint | string, decimals: number = 2): string => { - return e8sToLbry(e8s).toFixed(decimals); -}; - -// Helper to check GameStatus variants -export const isGameStatus = (status: { Won: null } | { Lost: null } | { Active: null }, check: "Won" | "Lost" | "Active"): boolean => { - return check in status; -}; - -// Helper to check TileState variants -export const isTileState = (state: { Mine: null } | { Revealed: null } | { Hidden: null }, check: "Mine" | "Revealed" | "Hidden"): boolean => { - return check in state; -}; diff --git a/src/alex_frontend/core/features/nft/components/Comment.tsx b/src/alex_frontend/core/features/nft/components/Comment.tsx index b2be684c0..c75b574ae 100644 --- a/src/alex_frontend/core/features/nft/components/Comment.tsx +++ b/src/alex_frontend/core/features/nft/components/Comment.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from "react"; import { Button } from "@/lib/components/button"; import { Textarea } from "@/lib/components/textarea"; -import { useDialectica } from "@/hooks/actors"; +import { useAlexBackend } from "@/hooks/actors"; import { useAppSelector } from "@/store/hooks/useAppSelector"; import { toast } from "sonner"; import { Principal } from "@dfinity/principal"; @@ -24,7 +24,7 @@ const Comment: React.FC = ({ arweaveId }) => { const [isLoading, setIsLoading] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); - const { actor } = useDialectica(); + const { actor } = useAlexBackend(); const { user } = useAppSelector((state) => state.auth); // Fetch comments diff --git a/src/alex_frontend/core/features/pinax/hooks/useUploadAndMint.ts b/src/alex_frontend/core/features/pinax/hooks/useUploadAndMint.ts index dc6669f0e..7b564761f 100644 --- a/src/alex_frontend/core/features/pinax/hooks/useUploadAndMint.ts +++ b/src/alex_frontend/core/features/pinax/hooks/useUploadAndMint.ts @@ -9,10 +9,12 @@ import estimateCost from '../thunks/estimateCost'; import fetchWallets from '../thunks/fetchWallets'; import selectWallet from '../thunks/selectWallet'; import processPayment from '../thunks/processPayment'; -import uploadFile from '../thunks/uploadFile'; +import uploadFile, { ArweaveTag } from '../thunks/uploadFile'; import mint from '../../nft/thunks/mint'; import { reset } from '../pinaxSlice'; +export type { ArweaveTag } from '../thunks/uploadFile'; + const validateFileType = (file: File) => { const typeInfo = getFileTypeInfo(file.type); if (!typeInfo) { @@ -43,7 +45,7 @@ export const useUploadAndMint = () => { const [success, setSuccess] = useState(null); const [loading, setLoading] = useState(false); - const uploadAndMint = async (file: File) => { + const uploadAndMint = async (file: File, tags?: ArweaveTag[]) => { setError(null); setSuccess(null); setLoading(true); @@ -74,7 +76,7 @@ export const useUploadAndMint = () => { // Step 7: Upload file const transaction = await dispatch( - uploadFile({ file, actor: walletActor }) + uploadFile({ file, actor: walletActor, tags }) ).unwrap(); // Step 8: Mint NFT diff --git a/src/alex_frontend/core/features/pinax/thunks/uploadFile.ts b/src/alex_frontend/core/features/pinax/thunks/uploadFile.ts index 7bef40f59..e1664c416 100644 --- a/src/alex_frontend/core/features/pinax/thunks/uploadFile.ts +++ b/src/alex_frontend/core/features/pinax/thunks/uploadFile.ts @@ -6,17 +6,23 @@ import { setProgress } from "../pinaxSlice"; import { readFileAsBuffer } from "../utils"; import { arweaveClient } from "@/utils/arweaveClient"; +export interface ArweaveTag { + name: string; + value: string; +} + const uploadFile = createAsyncThunk< string, // This is the return type of the thunk's payload { file: File; actor: ActorSubclass<_SERVICE>; + tags?: ArweaveTag[]; }, //Argument that we pass to initialize { rejectValue: string; dispatch: AppDispatch; state: RootState } >( "pinax/uploadFile", async ( - { file, actor }, + { file, actor, tags }, { rejectWithValue, dispatch, getState } ) => { try { @@ -31,10 +37,18 @@ const uploadFile = createAsyncThunk< transaction.setOwner(wallet.public.n); + // Add default tags transaction.addTag("Content-Type", file.type); - transaction.addTag("Application-Id", process.env.REACT_MAINNET_APP_ID!); - transaction.addTag("User-Principal", user?.principal || '2vxsx-fae'); - transaction.addTag("Version", "1.0"); + transaction.addTag("Application-Id", process.env.REACT_MAINNET_APP_ID!); + transaction.addTag("User-Principal", user?.principal || '2vxsx-fae'); + transaction.addTag("Version", "1.0"); + + // Add custom tags + if (tags && tags.length > 0) { + for (const tag of tags) { + transaction.addTag(tag.name, tag.value); + } + } const dataToSign = await transaction.getSignatureData(); diff --git a/src/alex_frontend/core/features/stake/components/StakeForm.tsx b/src/alex_frontend/core/features/stake/components/StakeForm.tsx index bcad31f69..16abd4357 100644 --- a/src/alex_frontend/core/features/stake/components/StakeForm.tsx +++ b/src/alex_frontend/core/features/stake/components/StakeForm.tsx @@ -175,7 +175,7 @@ const StakeForm: React.FC = () => { - If the transaction doesn't complete as expected, please check the Redeem Page to locate your tokens + If the transaction doesn't complete as expected, please check the Redeem Page to locate your tokens diff --git a/src/alex_frontend/core/hooks/actors/index.tsx b/src/alex_frontend/core/hooks/actors/index.tsx index 5af1626e7..b14867ce1 100644 --- a/src/alex_frontend/core/hooks/actors/index.tsx +++ b/src/alex_frontend/core/hooks/actors/index.tsx @@ -16,5 +16,3 @@ export { default as usePerpetua } from "./usePerpetua"; export { default as useIcpSwapFactory } from "./useIcpSwapFactory"; export { default as useLogs } from "./useLogs"; export { default as useEmporium } from "./useEmporium"; -export { default as useDialectica } from "./useDialectica"; -export { default as useKairos } from "./useKairos"; diff --git a/src/alex_frontend/core/hooks/actors/useDialectica.tsx b/src/alex_frontend/core/hooks/actors/useDialectica.tsx deleted file mode 100644 index ba4f88271..000000000 --- a/src/alex_frontend/core/hooks/actors/useDialectica.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { createActorHook } from "ic-use-actor"; -import { _SERVICE } from "../../../../declarations/dialectica/dialectica.did"; -import { canisterId, idlFactory } from "../../../../declarations/dialectica"; - -const useDialectica = createActorHook<_SERVICE>({ - canisterId: canisterId, - idlFactory: idlFactory, -}); - -export default useDialectica; \ No newline at end of file diff --git a/src/alex_frontend/core/hooks/actors/useKairos.tsx b/src/alex_frontend/core/hooks/actors/useKairos.tsx deleted file mode 100644 index fcd2b79f9..000000000 --- a/src/alex_frontend/core/hooks/actors/useKairos.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { createActorHook } from "ic-use-actor"; - -import type { _SERVICE } from "../../../../declarations/kairos/kairos.did"; - -import { - canisterId, - idlFactory, -} from "../../../../declarations/kairos"; - -const useKairos = createActorHook<_SERVICE>({ - canisterId: canisterId, - idlFactory: idlFactory, -}); - -export default useKairos; diff --git a/src/alex_frontend/core/hooks/useLogout.tsx b/src/alex_frontend/core/hooks/useLogout.tsx index 5507c8e80..9c6bd0474 100644 --- a/src/alex_frontend/core/hooks/useLogout.tsx +++ b/src/alex_frontend/core/hooks/useLogout.tsx @@ -21,8 +21,6 @@ import { useIcpSwapFactory, useLogs, useEmporium, - useDialectica, - useKairos, } from "@/hooks/actors"; export function useLogout() { @@ -47,8 +45,6 @@ export function useLogout() { const icpSwapFactory = useIcpSwapFactory(); const logs = useLogs(); const emporium = useEmporium(); - const dialectica = useDialectica(); - const kairos = useKairos(); const logout = async () => { await clear(); @@ -73,8 +69,6 @@ export function useLogout() { icpSwapFactory.reset(); logs.reset(); emporium.reset(); - dialectica.reset(); - kairos.reset(); // window.location.href = "/"; }; diff --git a/src/alex_frontend/core/providers/ActorProvider.tsx b/src/alex_frontend/core/providers/ActorProvider.tsx index 8ec278a31..a5470a184 100644 --- a/src/alex_frontend/core/providers/ActorProvider.tsx +++ b/src/alex_frontend/core/providers/ActorProvider.tsx @@ -30,10 +30,7 @@ import { useIcpSwapFactory, useLogs, useEmporium, - useDialectica, - useKairos, } from "@/hooks/actors"; -import { Button } from "@/lib/components/button"; // Component using multiple actors export default function ActorProvider() { @@ -57,8 +54,6 @@ export default function ActorProvider() { const icpSwapFactory = useIcpSwapFactory(); const logs = useLogs(); const emporium = useEmporium(); - const dialectica = useDialectica(); - const kairos = useKairos(); const onRequest = useCallback( (data: InterceptorRequestData) => { @@ -134,8 +129,6 @@ export default function ActorProvider() { icpSwapFactory.setInterceptors(interceptors); logs.setInterceptors(interceptors); emporium.setInterceptors(interceptors); - dialectica.setInterceptors(interceptors); - kairos.setInterceptors(interceptors); }); }, [interceptors]); diff --git a/src/alex_frontend/core/store/rootReducer.ts b/src/alex_frontend/core/store/rootReducer.ts index 4168983b0..495257863 100644 --- a/src/alex_frontend/core/store/rootReducer.ts +++ b/src/alex_frontend/core/store/rootReducer.ts @@ -42,7 +42,6 @@ import alexandrianReducer from '@/features/alexandrian/alexandrianSlice'; import nftReducer from '@/features/nft/slice'; import sonoraReducer from '@/features/sonora/sonoraSlice'; import bibliothecaReducer from '@/features/bibliotheca/bibliothecaSlice'; -import minesReducer from '@/features/mines/minesSlice'; const rootReducer = combineReducers({ home: homeReducer, @@ -90,7 +89,6 @@ const rootReducer = combineReducers({ nft: nftReducer, sonora: sonoraReducer, bibliotheca: bibliothecaReducer, - mines: minesReducer, }); export default rootReducer; diff --git a/src/alex_frontend/dialectica/src/components/PostCard.tsx b/src/alex_frontend/dialectica/src/components/PostCard.tsx index e3cde6702..397cf396d 100644 --- a/src/alex_frontend/dialectica/src/components/PostCard.tsx +++ b/src/alex_frontend/dialectica/src/components/PostCard.tsx @@ -1,5 +1,5 @@ import React, { useState, useRef, useEffect } from "react"; -import { ThumbsUp, ThumbsDown, MessageCircle, Share2, MoreVertical, ChevronDown, ChevronUp, Loader2, Twitter, Facebook, Mail, AtSign, Copy, X } from "lucide-react"; +import { ThumbsUp, ThumbsDown, MessageCircle, Share2, MoreVertical, ChevronDown, ChevronUp, Loader2, Twitter, Facebook, Mail, AtSign, Copy, X, TrendingUp } from "lucide-react"; import { Button } from "@/lib/components/button"; import { Card, CardContent } from "@/lib/components/card"; import { @@ -31,6 +31,7 @@ interface PostCardProps { likes?: number; dislikes?: number; comments?: number; + impressions?: number; userLiked?: boolean; userDisliked?: boolean; onLike?: () => Promise; @@ -49,6 +50,7 @@ const PostCard: React.FC = ({ likes = 0, dislikes = 0, comments = 0, + impressions = 0, userLiked = false, userDisliked = false, onLike, @@ -165,7 +167,9 @@ const PostCard: React.FC = ({ const formatTimestamp = (timestamp: string) => { try { - const date = new Date(parseInt(timestamp) / 1000000); // Convert nanoseconds to milliseconds + const ts = parseInt(timestamp); + // Timestamp is in milliseconds + const date = new Date(ts); const now = new Date(); const diffMs = now.getTime() - date.getTime(); const diffMinutes = Math.floor(diffMs / (1000 * 60)); @@ -358,6 +362,14 @@ const PostCard: React.FC = ({ {comments > 0 && {comments}} {showComments ? : } + + {/* Impressions */} + {impressions > 0 && ( + + + {impressions.toLocaleString()} + + )}
{/* Share Button with Slide-out Panel */} diff --git a/src/alex_frontend/dialectica/src/components/PostComposer.tsx b/src/alex_frontend/dialectica/src/components/PostComposer.tsx index c3710547a..0b12d2161 100644 --- a/src/alex_frontend/dialectica/src/components/PostComposer.tsx +++ b/src/alex_frontend/dialectica/src/components/PostComposer.tsx @@ -1,273 +1,449 @@ -import React, { useState, useRef } from "react"; -import { Upload, X, Image, Type, Film, Music } from "lucide-react"; +import React, { useState, useRef, useCallback } from "react"; +import { Image, Film, FileText, X, Loader2, Send, Sparkles } from "lucide-react"; import { Button } from "@/lib/components/button"; -import { Textarea } from "@/lib/components/textarea"; import { Card, CardContent } from "@/lib/components/card"; -import { useUploadAndMint } from "@/features/pinax/hooks/useUploadAndMint"; +import { useUploadAndMint, ArweaveTag } from "@/features/pinax/hooks/useUploadAndMint"; +import { useUploadOnly } from "../hooks/useUploadOnly"; import { useAppSelector } from "@/store/hooks/useAppSelector"; import { toast } from "sonner"; +import { PostData, APPLICATION_NAME, POST_VERSION } from "../types/post"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/lib/components/tooltip"; interface PostComposerProps { - onPostCreated?: () => void; + onPostCreated?: (arweaveId: string) => void; className?: string; } +type MediaType = "image" | "video" | "document"; + +const ACCEPTED_TYPES: Record = { + image: ".jpg,.jpeg,.png,.gif,.webp,.svg", + video: ".mp4,.webm,.mov", + document: ".pdf", +}; + +const MAX_CONTENT_LENGTH = 5000; + const PostComposer: React.FC = ({ onPostCreated, className = "", }) => { - const [postType, setPostType] = useState<'text' | 'media'>('text'); - const [textContent, setTextContent] = useState(''); + const [content, setContent] = useState(""); const [selectedFile, setSelectedFile] = useState(null); - const [isDragging, setIsDragging] = useState(false); + const [mediaPreviewUrl, setMediaPreviewUrl] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const [uploadingMedia, setUploadingMedia] = useState(false); + const [isDragOver, setIsDragOver] = useState(false); + const [isFocused, setIsFocused] = useState(false); const fileInputRef = useRef(null); - + const textareaRef = useRef(null); + const { uploadAndMint, isProcessing, progress, uploading, minting, resetUpload } = useUploadAndMint(); + const { upload: uploadMedia, isUploading: isMediaUploading, progress: mediaProgress } = useUploadOnly(); const { user } = useAppSelector((state) => state.auth); - const handleTextPost = async () => { - if (!textContent.trim() || !user) return; - - try { - // Create a text file blob - const textBlob = new Blob([textContent], { type: 'text/plain' }); - const textFile = new File([textBlob], 'post.txt', { type: 'text/plain' }); - - await uploadAndMint(textFile); - - // Reset form - setTextContent(''); - toast.success('Post created successfully!'); - onPostCreated?.(); - } catch (error) { - console.error('Failed to create text post:', error); - toast.error('Failed to create post'); + const handleFileSelect = (type: MediaType) => { + if (fileInputRef.current) { + fileInputRef.current.accept = ACCEPTED_TYPES[type]; + fileInputRef.current.click(); } }; - const handleMediaPost = async (file: File) => { - if (!user) return; + const processFile = useCallback((file: File) => { + // Validate file type + const isImage = file.type.startsWith("image/"); + const isVideo = file.type.startsWith("video/"); + const isPdf = file.type === "application/pdf"; - try { - await uploadAndMint(file); - - // Reset form - setSelectedFile(null); - if (fileInputRef.current) fileInputRef.current.value = ''; - toast.success('Post created successfully!'); - onPostCreated?.(); - } catch (error) { - console.error('Failed to create media post:', error); - toast.error('Failed to create post'); + if (!isImage && !isVideo && !isPdf) { + toast.error("Unsupported file type. Please use images, videos, or PDFs."); + return; + } + + // Validate file size (50MB max) + if (file.size > 50 * 1024 * 1024) { + toast.error("File is too large. Maximum size is 50MB."); + return; } - }; - const handleFileSelect = (file: File) => { setSelectedFile(file); - resetUpload(); - handleMediaPost(file); + if (isImage || isVideo) { + setMediaPreviewUrl(URL.createObjectURL(file)); + } else { + setMediaPreviewUrl(null); + } + }, []); + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + processFile(file); + } }; - const handleDragOver = (e: React.DragEvent) => { + const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); - setIsDragging(true); - }; + setIsDragOver(true); + }, []); - const handleDragLeave = (e: React.DragEvent) => { + const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); - setIsDragging(false); - }; + setIsDragOver(false); + }, []); - const handleDrop = (e: React.DragEvent) => { + const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); - setIsDragging(false); - const file = e.dataTransfer.files[0]; - if (file) handleFileSelect(file); - }; + setIsDragOver(false); + const file = e.dataTransfer.files?.[0]; + if (file) { + processFile(file); + } + }, [processFile]); - const handleFileInput = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (file) handleFileSelect(file); + const removeMedia = () => { + setSelectedFile(null); + if (mediaPreviewUrl) { + URL.revokeObjectURL(mediaPreviewUrl); + setMediaPreviewUrl(null); + } + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } }; - const getFilePreview = (file: File) => { - if (file.type.startsWith('image/')) { - return ( -
- Preview - -
- ); + const handleSubmit = async () => { + if (!user || (!content.trim() && !selectedFile)) return; + + setIsSubmitting(true); + resetUpload(); + + try { + let mediaArweaveId: string | undefined; + let mediaType: string | undefined; + let mediaFilename: string | undefined; + + // Step 1: Upload media first if present (without minting) + if (selectedFile) { + setUploadingMedia(true); + mediaArweaveId = await uploadMedia(selectedFile); + mediaType = selectedFile.type; + mediaFilename = selectedFile.name; + setUploadingMedia(false); + } + + // Step 2: Create post JSON + const postData: PostData = { + content: content.trim(), + createdAt: Date.now(), + version: POST_VERSION, + ...(mediaArweaveId && { + mediaArweaveId, + mediaType, + mediaFilename, + }), + }; + + // Step 3: Create JSON file and upload + mint with tags + const jsonBlob = new Blob([JSON.stringify(postData)], { type: "application/json" }); + const jsonFile = new File([jsonBlob], "post.json", { type: "application/json" }); + + const tags: ArweaveTag[] = [ + { name: "Application-Name", value: APPLICATION_NAME }, + { name: "Environment", value: process.env.DFX_NETWORK === "ic" ? "production" : "local" }, + ]; + + const transactionId = await uploadAndMint(jsonFile, tags); + + // Reset form + setContent(""); + removeMedia(); + toast.success("Post created successfully!"); + + if (transactionId) { + onPostCreated?.(transactionId); + } + } catch (error: any) { + console.error("Failed to create post:", error); + toast.error(error.message || "Failed to create post"); + } finally { + setIsSubmitting(false); + setUploadingMedia(false); } + }; + + const renderMediaPreview = () => { + if (!selectedFile) return null; return ( -
-
- {file.type.startsWith('video/') ? ( - - ) : file.type.startsWith('audio/') ? ( - - ) : ( - - )} -
-
-

{file.name}

-

- {file.type} • {(file.size / 1024 / 1024).toFixed(2)} MB -

-
- +
); }; + const isDisabled = isSubmitting || isProcessing || isMediaUploading; + const canSubmit = (content.trim() || selectedFile) && !isDisabled; + const contentLength = content.length; + const isNearLimit = contentLength > MAX_CONTENT_LENGTH * 0.9; + const isOverLimit = contentLength > MAX_CONTENT_LENGTH; + + const getProgressInfo = () => { + if (uploadingMedia) return { text: "Uploading media...", progress: mediaProgress, step: 1 }; + if (uploading) return { text: "Uploading post...", progress: progress, step: 2 }; + if (minting) return { text: "Minting on-chain...", progress: 100, step: 3 }; + return { text: "Processing...", progress: 0, step: 0 }; + }; + if (!user) { return ( - - -

- Please sign in to create posts -

+ + +
+ +
+

Join the conversation

+

Sign in to share your thoughts with the community

); } + const progressInfo = getProgressInfo(); + return ( - - - {/* Post Type Toggle */} -
- - + + + {/* Main input area */} +
+