{/* Left Sidebar */}
-
+
@@ -58,33 +71,78 @@ const SonoraMarketPage: React.FC = () => {
- {mockAudio.length} items for sale
+ {audios.length} of {pagination.totalCount} items shown
{/* Load More */}
-
+ {pagination.page < pagination.totalPages && (
+
+ )}
{/* Audio Grid */}
- {mockAudio.map((item) => (
-
-
-
- >
- }
- />
- ))}
+ {loading ? (
+
+
+
+
Loading marketplace audio NFTs...
+
+
+ ) : error ? (
+
+
+
Error loading marketplace
+
{error}
+
+
+
+ ) : audios.length === 0 ? (
+
+
+
+
No Items for Sale
+
There are currently no audio NFTs listed in the marketplace.
+
+
+ ) : (
+ audios.map((audio) => (
+
+ }
+ />
+ ))
+ )}
);
diff --git a/src/alex_frontend/src/pages/sonora/RecordPage.tsx b/src/alex_frontend/src/pages/sonora/RecordPage.tsx
index 831320b4d..6c3668940 100644
--- a/src/alex_frontend/src/pages/sonora/RecordPage.tsx
+++ b/src/alex_frontend/src/pages/sonora/RecordPage.tsx
@@ -1,26 +1,31 @@
import React, { useState, useRef } from "react";
import { Button } from "@/lib/components/button";
-import { Mic, Square, Play, Upload, Trash2 } from "lucide-react";
+import { Mic, Square, Upload, Trash2, LoaderPinwheel } from "lucide-react";
import { Link } from "@tanstack/react-router";
import { useAppDispatch } from "@/store/hooks/useAppDispatch";
-import { playAudio, clearSelected } from "@/features/sonora/sonoraSlice";
+import { useAppSelector } from "@/store/hooks/useAppSelector";
+import { clearSelected, setSelected } from "@/features/sonora/sonoraSlice";
import { Audio } from "@/features/sonora/types";
+import { AudioCard } from "@/features/sonora/components/AudioCard";
+import { useUploadAndMint } from "@/features/pinax/hooks/useUploadAndMint";
const SonoraRecordPage: React.FC = () => {
const [isRecording, setIsRecording] = useState(false);
const [recordedBlob, setRecordedBlob] = useState
(null);
const [recordedUrl, setRecordedUrl] = useState("");
const [recordingTime, setRecordingTime] = useState(0);
- const [error, setError] = useState("");
+ const [recordingError, setRecordingError] = useState("");
+ const { uploadAndMint, isProcessing, error: uploadError, success, progress, estimating, uploading, minting, resetUpload } = useUploadAndMint();
const mediaRecorderRef = useRef(null);
const streamRef = useRef(null);
const timerRef = useRef(null);
const dispatch = useAppDispatch();
+ const { selected } = useAppSelector((state) => state.sonora);
const startRecording = async () => {
try {
- setError("");
+ setRecordingError("");
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream;
@@ -40,6 +45,15 @@ const SonoraRecordPage: React.FC = () => {
setRecordedBlob(blob);
const url = URL.createObjectURL(blob);
setRecordedUrl(url);
+
+ // Automatically create audio data for preview
+ const audioData: Audio = {
+ id: url,
+ type: 'audio/webm',
+ size: `${(blob.size / (1024 * 1024)).toFixed(2)} MB`,
+ timestamp: new Date().toISOString()
+ };
+ dispatch(setSelected(audioData));
};
mediaRecorder.start();
@@ -52,7 +66,7 @@ const SonoraRecordPage: React.FC = () => {
}, 1000);
} catch (err) {
- setError("Failed to access microphone. Please check permissions.");
+ setRecordingError("Failed to access microphone. Please check permissions.");
}
};
@@ -71,19 +85,6 @@ const SonoraRecordPage: React.FC = () => {
}
};
- const playRecording = () => {
- if (recordedUrl && recordedBlob) {
- // Create Audio object for Redux
- const audioData: Audio = {
- id: recordedUrl, // Use object URL as ID for recorded files
- type: 'audio/webm',
- size: `${(recordedBlob.size / (1024 * 1024)).toFixed(2)} MB`,
- timestamp: new Date().toISOString()
- };
-
- dispatch(playAudio(audioData));
- }
- };
const deleteRecording = () => {
if (recordedUrl) {
@@ -93,13 +94,40 @@ const SonoraRecordPage: React.FC = () => {
setRecordedUrl("");
setRecordingTime(0);
dispatch(clearSelected());
+ // Reset upload state
+ resetUpload();
};
- const handleUpload = () => {
+ const handleUpload = async () => {
if (recordedBlob) {
- // TODO: Implement upload logic
- console.log("Uploading recorded audio");
- alert("Upload functionality coming soon!");
+ try {
+ // Convert blob to File object
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
+ const fileName = `sonora-recording-${timestamp}.webm`;
+ const file = new File([recordedBlob], fileName, { type: 'audio/webm' });
+
+ const transactionId = await uploadAndMint(file);
+ // Replace blob URL with Arweave transaction URL
+ if (recordedUrl) {
+ URL.revokeObjectURL(recordedUrl);
+ }
+ const arweaveUrl = `https://arweave.net/${transactionId}`;
+ setRecordedUrl(arweaveUrl);
+
+ // Update the audio data with Arweave URL
+ const audioData: Audio = {
+ id: transactionId, // Use transaction ID as the ID
+ type: 'audio/webm',
+ size: `${(recordedBlob.size / (1024 * 1024)).toFixed(2)} MB`,
+ timestamp: new Date().toISOString()
+ };
+ dispatch(setSelected(audioData));
+
+ // Clear the blob reference but keep the UI showing the uploaded file
+ setRecordedBlob(null);
+ } catch (error) {
+ // Error handling is done in the hook, keep recording for retry
+ }
}
};
@@ -119,87 +147,136 @@ const SonoraRecordPage: React.FC = () => {
- {/* Recording Status */}
-
-
-
-
-
-
- {formatTime(recordingTime)}
+
+
+ {/* Recording Status */}
+
+
+
+
+
+
+ {formatTime(recordingTime)}
+
+
+ {isRecording && (
+
+ Recording in progress...
+
+ )}
+
+ {recordedBlob && !isRecording && (
+
+ Recording completed
+
+ )}
-
- {isRecording && (
-
- Recording in progress...
-
- )}
-
- {recordedBlob && !isRecording && (
-
- Recording completed
-
- )}
-
- {/* Error Message */}
- {error && (
-
- )}
-
- {/* Control Buttons */}
-
- {!isRecording && !recordedBlob && (
-
- )}
-
- {isRecording && (
-
+ {/* Error Message */}
+ {recordingError && (
+
)}
-
- {recordedBlob && !isRecording && (
- <>
-
+
+ {/* Upload option below recording box */}
+
+
+
+
+ or
+
+ upload an existing audio file
- >
- )}
+
+
- {/* Upload option */}
-
-
- or{" "}
-
- upload an existing audio file
-
-
-
+ {/* Audio Preview */}
+ {(recordedBlob || recordedUrl) && selected && !isRecording && (
+
+
+
+ {recordedBlob ? "Preview" : "Uploaded to Arweave"}
+
+
+
+ {recordedBlob ? "Delete" : "Clear"}
+
+
+
+
+ )}
+
+ {/* Upload Error and Success Messages */}
+ {uploadError && (
+
+ )}
+
+ {success && (
+
+ )}
+
+ {/* Upload Button */}
+ {recordedBlob && (
+
+
+ {isProcessing ? (
+ <>
+
+ {estimating ? "Estimating..." : uploading ? `Uploading ${Math.round(progress)}%` : minting ? "Minting..." : "Processing..."}
+ >
+ ) : (
+ <>
+
+ Upload & Mint NFT
+ >
+ )}
+
+
+ )}
);
diff --git a/src/alex_frontend/src/pages/sonora/StudioPage.tsx b/src/alex_frontend/src/pages/sonora/StudioPage.tsx
index 53e57741e..173f11b03 100644
--- a/src/alex_frontend/src/pages/sonora/StudioPage.tsx
+++ b/src/alex_frontend/src/pages/sonora/StudioPage.tsx
@@ -1,17 +1,34 @@
-import React from "react";
+import React, { useEffect } from "react";
import { Button } from "@/lib/components/button";
-import { Palette, Settings, TrendingUp, BarChart3, ArrowDown, FileAudio } from "lucide-react";
-import { mockAudio } from "@/features/sonora/data";
+import { Palette, Settings, TrendingUp, BarChart3, ArrowDown, FileAudio, LoaderPinwheel } from "lucide-react";
import { AudioCard } from "@/features/sonora/components/AudioCard";
-import { PlayPauseButton } from "@/features/sonora/components/PlayPauseButton";
import { EditButton } from "@/features/sonora/components/EditButton";
import { UnlistButton } from "@/features/sonora/components/UnlistButton";
+import { useStudioAudioNFTs } from "@/features/sonora/hooks/useStudioAudioNFTs";
+import { useAppSelector } from "@/store/hooks/useAppSelector";
const SonoraStudioPage: React.FC = () => {
+ const { user } = useAppSelector((state) => state.auth);
+ const { audios, loading, loadingMore, error, pagination, refreshStudioAudioNFTs } = useStudioAudioNFTs();
+
+ // Fetch user's listed audio NFTs on component mount
+ useEffect(() => {
+ if (user?.principal) {
+ refreshStudioAudioNFTs(user.principal, 1, 8, false); // First page, not append mode
+ }
+ }, [user?.principal]);
+
+ // Load More handler
+ const handleLoadMore = () => {
+ if (user?.principal && !loadingMore && pagination.page < pagination.totalPages) {
+ refreshStudioAudioNFTs(user.principal, pagination.page + 1, 8, true); // Next page, append mode
+ }
+ };
+
return (
-
+
{/* Left Sidebar */}
-
+
@@ -59,34 +76,83 @@ const SonoraStudioPage: React.FC = () => {
- {mockAudio.length} active listings
+ {audios.length} of {pagination.totalCount} listings shown
{/* Load More */}
-
-
- Load More
-
+ {pagination.page < pagination.totalPages && (
+
+ {loadingMore ? (
+
+ ) : (
+
+ )}
+ {loadingMore ? "Loading..." : "Load More"}
+
+ )}
{/* Audio Grid */}
- {mockAudio.map((item) => (
-
-
-
-
- >
- }
- />
- ))}
+ {loading ? (
+
+
+
+
Loading your listed audio NFTs...
+
+
+ ) : error ? (
+
+
+
Error loading listed audio NFTs
+
{error}
+
user?.principal && refreshStudioAudioNFTs(user.principal, 1, 8, false)}
+ className="mt-4"
+ variant="outline"
+ >
+ Try Again
+
+
+
+ ) : audios.length === 0 ? (
+
+
+
+
No Active Listings
+
You don't have any audio NFTs listed for sale yet.
+
+
+ ) : (
+ audios.map((item) => (
+
+
+
+ >
+ }
+ />
+ ))
+ )}
);
diff --git a/src/alex_frontend/src/pages/sonora/UploadPage.tsx b/src/alex_frontend/src/pages/sonora/UploadPage.tsx
index 58a105d62..4e8d2516d 100644
--- a/src/alex_frontend/src/pages/sonora/UploadPage.tsx
+++ b/src/alex_frontend/src/pages/sonora/UploadPage.tsx
@@ -1,10 +1,13 @@
import React, { useState, useRef } from "react";
import { Button } from "@/lib/components/button";
-import { Upload, Mic, X } from "lucide-react";
+import { Upload, Mic, X, LoaderPinwheel } from "lucide-react";
import { Link } from "@tanstack/react-router";
import { useAppDispatch } from "@/store/hooks/useAppDispatch";
-import { playAudio, clearSelected } from "@/features/sonora/sonoraSlice";
+import { useAppSelector } from "@/store/hooks/useAppSelector";
+import { setSelected, clearSelected } from "@/features/sonora/sonoraSlice";
import { Audio } from "@/features/sonora/types";
+import { AudioCard } from "@/features/sonora/components/AudioCard";
+import { useUploadAndMint } from "@/features/pinax/hooks/useUploadAndMint";
const SonoraUploadPage: React.FC = () => {
const [selectedFile, setSelectedFile] = useState
(null);
@@ -12,6 +15,8 @@ const SonoraUploadPage: React.FC = () => {
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef(null);
const dispatch = useAppDispatch();
+ const { selected } = useAppSelector((state) => state.sonora);
+ const { uploadAndMint, isProcessing, error, success, progress, estimating, uploading, minting, resetUpload } = useUploadAndMint();
const handleFileSelect = (file: File) => {
// Validate audio file
@@ -34,7 +39,7 @@ const SonoraUploadPage: React.FC = () => {
};
// Set audio in global state for player
- dispatch(playAudio(audioData));
+ dispatch(setSelected(audioData));
};
const handleFileInput = (e: React.ChangeEvent) => {
@@ -68,13 +73,32 @@ const SonoraUploadPage: React.FC = () => {
if (fileInputRef.current) fileInputRef.current.value = "";
// Clear audio from global state
dispatch(clearSelected());
+ // Reset upload state
+ resetUpload();
};
- const handleUpload = () => {
+ const handleUpload = async () => {
if (selectedFile) {
- // TODO: Implement upload logic
- console.log("Uploading file:", selectedFile.name);
- alert("Upload functionality coming soon!");
+ try {
+ const transactionId = await uploadAndMint(selectedFile);
+ // Replace blob URL with Arweave transaction URL
+ if (audioUrl) {
+ URL.revokeObjectURL(audioUrl);
+ }
+ const arweaveUrl = `https://arweave.net/${transactionId}`;
+ setAudioUrl(arweaveUrl);
+
+ // Update the audio data with Arweave URL
+ const audioData: Audio = {
+ id: transactionId, // Use transaction ID as the ID
+ type: selectedFile.type,
+ size: `${(selectedFile.size / (1024 * 1024)).toFixed(2)} MB`,
+ timestamp: new Date().toISOString()
+ };
+ dispatch(setSelected(audioData));
+ } catch (error) {
+ // Error handling is done in the hook, keep file for retry
+ }
}
};
@@ -140,22 +164,13 @@ const SonoraUploadPage: React.FC = () => {
- {/* Selected File Info */}
- {selectedFile && (
-
+ {/* Audio Preview */}
+ {selectedFile && selected && (
+
-
-
- {selectedFile.name}
-
-
- {(
- selectedFile.size /
- (1024 * 1024)
- ).toFixed(2)}{" "}
- MB • {selectedFile.type}
-
-
+
+ {audioUrl.startsWith('blob:') ? "Preview" : "Uploaded to Arweave"}
+
{
className="text-muted-foreground hover:text-foreground"
>
+ {audioUrl.startsWith('blob:') ? "Remove" : "Clear"}
+
+
+ )}
+
+ {/* Error and Success Messages */}
+ {error && (
+
+ )}
+
+ {success && (
+
)}
{/* Upload Button */}
- {selectedFile && (
+ {selectedFile && audioUrl.startsWith('blob:') && (
-
-
- Upload & Mint NFT
+
+ {isProcessing ? (
+ <>
+
+ {estimating ? "Estimating..." : uploading ? `Uploading ${Math.round(progress)}%` : minting ? "Minting..." : "Processing..."}
+ >
+ ) : (
+ <>
+
+ Upload & Mint NFT
+ >
+ )}
)}
diff --git a/src/alex_frontend/src/pages/sonora/index.tsx b/src/alex_frontend/src/pages/sonora/index.tsx
index 64514d5b5..21e37f699 100644
--- a/src/alex_frontend/src/pages/sonora/index.tsx
+++ b/src/alex_frontend/src/pages/sonora/index.tsx
@@ -1,17 +1,55 @@
-import React from "react";
+import React, { useEffect } from "react";
import { Button } from "@/lib/components/button";
-import { Database, Globe, ArrowDown, FileAudio, Coins } from "lucide-react";
-import { mockAudio } from "@/features/sonora/data";
+import { Database, Globe, ArrowDown, FileAudio, Coins, AlertCircle, Loader2 } from "lucide-react";
import { AudioCard } from "@/features/sonora/components/AudioCard";
-import { PlayPauseButton } from "@/features/sonora/components/PlayPauseButton";
import { MintButton } from "@/features/sonora/components/MintButton";
+import { useArweaveAudios } from "@/features/sonora/hooks/useArweaveAudios";
+import { fetchAudios } from "@/features/sonora/browse/thunks/fetchAudios";
+import { useAppDispatch } from "@/store/hooks/useAppDispatch";
+import { ArweaveAudio, Audio } from "@/features/sonora/types";
const SonoraPage: React.FC = () => {
+ const dispatch = useAppDispatch();
+ const { audios, loading, error, hasNext, loadMore, isEmpty } = useArweaveAudios();
+
+ // Convert ArweaveAudio to Audio format for AudioCard
+ const convertToAudio = (arweaveAudio: ArweaveAudio): Audio => {
+ // Try to get content type from data.type or Content-Type tag
+ let contentType = arweaveAudio.data?.type;
+ if (!contentType) {
+ const contentTypeTag = arweaveAudio.tags?.find(tag => tag.name === "Content-Type");
+ contentType = contentTypeTag?.value || '';
+ }
+
+ return {
+ id: arweaveAudio.id,
+ type: contentType,
+ size: arweaveAudio.data?.size || null,
+ timestamp: new Date(arweaveAudio.block.timestamp * 1000).toISOString()
+ };
+ };
+
+ // Fetch initial data on mount
+ useEffect(() => {
+ if (audios.length === 0 && !loading && !error) {
+ dispatch(fetchAudios({ reset: true }));
+ }
+ }, [dispatch, audios.length, loading, error]);
+
+ const formatFileSize = (sizeString: string | null) => {
+ if (!sizeString) return 'Unknown size';
+ const size = parseInt(sizeString);
+ if (isNaN(size)) return 'Unknown size';
+ if (size < 1024 * 1024) {
+ return `${(size / 1024).toFixed(1)} KB`;
+ }
+ return `${(size / (1024 * 1024)).toFixed(1)} MB`;
+ };
return (
-
+
{/* Left Sidebar */}
-
+
@@ -59,33 +97,75 @@ const SonoraPage: React.FC = () => {
- {mockAudio.length} files available
+ {loading && audios.length === 0 ? (
+ "Loading files..."
+ ) : (
+ <>
+ {audios.length} files available
+ >
+ )}
{/* Load More */}
-
-
- Load More
-
+ {hasNext && (
+
+ {loading ? (
+
+ ) : (
+
+ )}
+ {loading ? "Loading..." : "Load More"}
+
+ )}
{/* Audio Grid */}
- {mockAudio.map((item) => (
-
-
-
- >
- }
- />
- ))}
+ {error && (
+
+ )}
+
+ {loading && audios.length === 0 && (
+
+
+ Loading audio files from Arweave...
+
+ )}
+
+ {isEmpty && !loading && (
+
+
+
No audio files found
+
Try refreshing the page
+
+ )}
+
+ {audios.map((arweaveAudio) => {
+ const audioItem = convertToAudio(arweaveAudio);
+ // Format size better
+ audioItem.size = formatFileSize(audioItem.size);
+
+ return (
+
+ }
+ />
+ );
+ })}
diff --git a/src/alex_frontend/src/store/rootReducer.ts b/src/alex_frontend/src/store/rootReducer.ts
index 55f65ff5f..bb142547e 100644
--- a/src/alex_frontend/src/store/rootReducer.ts
+++ b/src/alex_frontend/src/store/rootReducer.ts
@@ -46,6 +46,9 @@ import permasearchReducer from '@/features/permasearch/store/slice';
import alexandrianReducer from '@/features/alexandrian/alexandrianSlice';
import nftReducer from '@/features/nft/slice';
import sonoraReducer from '@/features/sonora/sonoraSlice';
+import archiveReducer from '@/features/sonora/archiveSlice';
+import studioReducer from '@/features/sonora/studioSlice';
+import marketReducer from '@/features/sonora/marketSlice';
const rootReducer = combineReducers({
home: homeReducer,
@@ -96,6 +99,9 @@ const rootReducer = combineReducers({
alexandrian: alexandrianReducer,
nft: nftReducer,
sonora: sonoraReducer,
+ archive: archiveReducer,
+ studio: studioReducer,
+ market: marketReducer,
});
export default rootReducer;
diff --git a/src/alex_frontend/src/styles/tailwind.css b/src/alex_frontend/src/styles/tailwind.css
index 79e2596de..6d59d4fe7 100644
--- a/src/alex_frontend/src/styles/tailwind.css
+++ b/src/alex_frontend/src/styles/tailwind.css
@@ -101,4 +101,9 @@
@apply bg-background text-foreground;
font-feature-settings: "rlig" 1, "calt" 1;
}
+
+ @keyframes shimmer {
+ 0% { left: -50%; }
+ 100% { left: 100%; }
+ }
}
\ No newline at end of file
diff --git a/src/nft_manager/nft_manager.did b/src/nft_manager/nft_manager.did
index f74321d7c..e934dc4b8 100644
--- a/src/nft_manager/nft_manager.did
+++ b/src/nft_manager/nft_manager.did
@@ -1,11 +1,26 @@
type Account = record { owner : principal; subaccount : opt blob };
+type BTreeMap = vec record {
+ text;
+ variant {
+ Int : int;
+ Map : BTreeMap;
+ Nat : nat;
+ Nat64 : nat64;
+ Blob : blob;
+ Text : text;
+ Array : vec Value;
+ };
+};
type Result = variant { Ok : nat; Err : text };
type Result_1 = variant { Ok : text; Err : text };
type Result_10 = variant { Ok : vec opt Account; Err : text };
type Result_11 = variant { Ok : record { opt nat; opt nat }; Err : text };
type Result_2 = variant { Ok; Err : text };
type Result_3 = variant { Ok : vec opt text; Err : text };
-type Result_4 = variant { Ok : vec opt vec record { text; Value }; Err : text };
+type Result_4 = variant {
+ Ok : vec opt vec record { text; Value_1 };
+ Err : text;
+};
type Result_5 = variant { Ok : vec record { nat; TokenBalances }; Err : text };
type Result_6 = variant { Ok : vec TokenBalances; Err : text };
type Result_7 = variant { Ok : vec nat; Err : text };
@@ -13,6 +28,15 @@ type Result_8 = variant { Ok : vec record { nat; opt text }; Err : text };
type Result_9 = variant { Ok : vec bool; Err : text };
type TokenBalances = record { alex : nat; lbry : nat };
type Value = variant {
+ Int : int;
+ Map : BTreeMap;
+ Nat : nat;
+ Nat64 : nat64;
+ Blob : blob;
+ Text : text;
+ Array : vec Value;
+};
+type Value_1 = variant {
Int : int;
Map : vec record { text; Value };
Nat : nat;