diff --git a/SONORA.md b/SONORA.md
new file mode 100644
index 000000000..f0c94f3dd
--- /dev/null
+++ b/SONORA.md
@@ -0,0 +1,200 @@
+# Sonora - Audio/Podcast App Specification
+
+## App Concept: "Sonora"
+*Named after the desert known for its acoustic properties*
+
+## Core Features
+
+### For Content Creators:
+- Record audio directly in browser OR upload audio files (podcasts, audiobooks, music)
+- One-click upload and mint flow (no metadata required initially)
+- Audio files automatically minted as NFTs during upload
+- Simple pricing system for audio NFTs
+- Basic analytics (plays, sales)
+- Revenue from direct NFT sales
+
+### For Listeners:
+- Browse/discover audio content by category, creator, popularity
+- Stream audio with player controls (play/pause, seek, speed control)
+- Purchase audio NFTs to own content
+- Create playlists and favorites
+- Follow creators and get notifications
+- Leave reviews and ratings
+
+### Marketplace Features:
+- Buy/sell audio NFTs
+- Transfer ownership rights
+- Licensing marketplace for commercial use
+- Revenue sharing for collaborations
+
+## Integration with Existing System
+
+**Leverage existing canisters:**
+- **asset_manager** - Store audio files
+- **emporium** - Handle marketplace transactions
+- **nft_manager** - Mint audio NFTs
+- **tokenomics** - Handle payments (ALEX/LBRY tokens)
+- **authentication** - User login
+- **perpetua** - Content categorization/tagging
+
+## Sonora App Pages Structure
+
+### Main Discovery
+1. **`/app/sonora`** - Main browse page (all audio NFTs from Arweave)
+
+### Upload/Create
+2. **`/app/sonora/upload`** - Upload audio files (with preview player at bottom)
+3. **`/app/sonora/record`** - Record audio directly (with preview player at bottom)
+
+### Owned Audios (Not Listed for Sale)
+4. **`/app/sonora/archive`** - Current user's owned audios (private collection)
+5. **`/app/sonora/archive/:principal`** - View another user's owned audios
+
+### Listed Items (For Sale)
+6. **`/app/sonora/studio`** - Current user's listed items for sale
+7. **`/app/sonora/studio/:principal`** - View another user's listed items (can buy here)
+
+### Marketplace
+8. **`/app/sonora/market`** - All listed items from all users (global marketplace)
+
+### Global Audio Player
+- **Bottom Player Component** - Persistent audio player at bottom of all Sonora pages
+ - Shows on: `/app/sonora`, `/app/sonora/market`, `/app/sonora/archive`, `/app/sonora/studio`
+ - Click any audio → starts playing in bottom player
+ - Stays active when navigating between pages
+ - Standard controls: play/pause, seek, volume, track info
+
+## Code Structure (Following Current Patterns)
+
+### 1. Routes (TanStack Router)
+```
+src/routes/app/
+├── sonora.tsx # Route definition
+├── sonora.lazy.tsx # Lazy loaded main page
+├── sonora/
+ ├── upload.tsx # Upload route
+ ├── upload.lazy.tsx
+ ├── record.tsx # Record route
+ ├── record.lazy.tsx
+ ├── archive.tsx # Archive route
+ ├── archive.lazy.tsx
+ ├── archive.$principal.tsx # Other user's archive
+ ├── archive.$principal.lazy.tsx
+ ├── studio.tsx # Studio route
+ ├── studio.lazy.tsx
+ ├── studio.$principal.tsx # Other user's studio
+ ├── studio.$principal.lazy.tsx
+ ├── market.tsx # Market route
+ └── market.lazy.tsx
+```
+
+### 2. Features Structure
+```
+src/features/sonora/
+├── index.ts # Export all components/hooks/actions
+├── components/
+│ ├── AudioCard.tsx # Audio NFT card component
+│ ├── AudioPlayer.tsx # Bottom audio player
+│ ├── AudioRecorder.tsx # Browser recording interface
+│ ├── AudioUploader.tsx # File upload interface
+│ ├── SortToggle.tsx # Latest/Oldest toggle
+│ ├── ListingModal.tsx # Modal to list audio for sale
+│ └── EmptyState.tsx # Empty states for different pages
+├── hooks/
+│ ├── useAudioSearch.ts # Fetch audios from Arweave
+│ ├── useAudioPlayer.ts # Audio player state management
+│ ├── useAudioUpload.ts # Upload flow
+│ ├── useAudioRecord.ts # Recording functionality
+│ └── useUserAudios.ts # User's owned/listed audios
+├── store/
+│ └── sonoraSlice.ts # Redux slice for Sonora state
+├── thunks/
+│ ├── fetchAudios.ts # Fetch from Arweave
+│ ├── uploadAudio.ts # Upload + mint flow
+│ ├── listAudio.ts # List for sale
+│ └── purchaseAudio.ts # Buy audio NFT
+├── api/
+│ ├── arweave.ts # Arweave audio queries
+│ └── marketplace.ts # NFT marketplace calls
+├── types/
+│ └── index.ts # Audio, Player, Listing types
+└── utils/
+ ├── audioHelpers.ts # Audio format validation
+ └── playerUtils.ts # Player utilities
+```
+
+### 3. Pages
+```
+src/pages/sonora/
+├── index.tsx # Main browse page (component: SonoraPage)
+├── UploadPage.tsx # Upload page (component: SonoraUploadPage)
+├── RecordPage.tsx # Record page (component: SonoraRecordPage)
+├── ArchivePage.tsx # Archive page (component: SonoraArchivePage)
+├── StudioPage.tsx # Studio page (component: SonoraStudioPage)
+└── MarketPage.tsx # Market page (component: SonoraMarketPage)
+```
+
+### 4. Global Components
+```
+src/components/
+└── AudioPlayer/ # Global bottom player
+ ├── index.tsx
+ ├── Controls.tsx
+ ├── ProgressBar.tsx
+ ├── VolumeControl.tsx
+ └── TrackInfo.tsx
+```
+
+### 6. Layout & Navigation Structure (Following Exchange/Emporium Pattern)
+```
+src/layouts/
+├── SonoraLayout.tsx # Main Sonora layout (following ExchangeLayout/EmporiumLayout)
+```
+
+**Layout Pattern (exactly like ExchangeLayout/EmporiumLayout):**
+- SonoraLayout structure:
+ - Main container with padding and flex column
+ - Header section:
+ - App title: "Sonora" (xxltabsheading, font-syne, bold, center, primary color)
+ - Description: "Create, discover and trade audio content" (smtabsheading, center, muted-foreground, roboto-condensed)
+ - Navigation section:
+ - Card background with rounded borders and shadow
+ - Horizontal nav with Link components (TanStack Router)
+ - Active/inactive styling with transitions
+ - Outlet for nested routes
+ - Bottom audio player component
+
+**Navigation items (Horizontal Link tabs):**
+- Browse (`/app/sonora` - exact match, default active)
+- Upload (`/app/sonora/upload`)
+- Record (`/app/sonora/record`)
+- Archive (`/app/sonora/archive`)
+- Studio (`/app/sonora/studio`)
+- Market (`/app/sonora/market`)
+
+### 5. Config Updates
+```
+src/config/apps.ts # Add Sonora to apps list with placeholder image
+```
+
+## Technical Decisions (TO BE MADE)
+- [ ] Audio format support (.mp3, .wav, .ogg)
+- [ ] Streaming vs download approach
+- [ ] File size limitations
+- [ ] Quality options
+- [ ] Offline playback support
+
+## Implementation Notes
+- Using TanStack Router for routing
+- TanStack Query for data fetching
+- Redux Toolkit with thunks for state management
+- Tailwind CSS + Shadcn components
+- Features pattern for organization
+- Following existing permasearch/pinax patterns
+
+## Discussion Notes
+*This section will be updated with our decisions as we discuss each feature*
+
+---
+**Last Updated:** 2025-10-26
+**Status:** Detailed specification with code structure
\ No newline at end of file
diff --git a/dfx.json b/dfx.json
index a67e46212..db11b4cd4 100644
--- a/dfx.json
+++ b/dfx.json
@@ -94,7 +94,6 @@
"id": "ysy5f-2qaaa-aaaap-qkmmq-cai"
},
"alex_frontend": {
- "dependencies": ["alex_backend"],
"frontend": {
"entrypoint": "src/alex_frontend/public/index.html"
},
diff --git a/src/alex_frontend/src/components/AudioPlayer.tsx b/src/alex_frontend/src/components/AudioPlayer.tsx
new file mode 100644
index 000000000..6f4249f01
--- /dev/null
+++ b/src/alex_frontend/src/components/AudioPlayer.tsx
@@ -0,0 +1,25 @@
+import React from 'react';
+import { useAppSelector } from '@/store/hooks/useAppSelector';
+
+const AudioPlayer: React.FC = () => {
+ const { selected } = useAppSelector((state) => state.sonora);
+
+ // Check if it's a local file (starts with blob:) or Arweave file
+ const audioUrl = selected ?
+ (selected.id.startsWith('blob:') || selected.id.includes('.') ?
+ selected.id : `https://arweave.net/${selected.id}`) : '';
+
+ return (
+
+
+
+ );
+};
+
+export default AudioPlayer;
diff --git a/src/alex_frontend/src/config/apps.ts b/src/alex_frontend/src/config/apps.ts
index 8274c0f79..3de03d288 100644
--- a/src/alex_frontend/src/config/apps.ts
+++ b/src/alex_frontend/src/config/apps.ts
@@ -14,6 +14,7 @@ export const appsData: App[] = [
{ name: 'Permasearch', description: 'Explore', path: '/app/permasearch', logo: '/logos/Permasearch.svg' },
{ name: 'Emporium', description: 'Trade', path: '/app/emporium', logo: '/logos/Emporium.svg' },
{ name: 'Pinax', description: 'Upload', path: '/app/pinax', logo: '/logos/Pinax.svg', comingSoon: false },
+ { name: 'Sonora', description: 'Audio', path: '/app/sonora', logo: `https://picsum.photos/seed/${Date.now()}/300/300`, comingSoon: true },
{ name: 'Syllogos', description: 'Aggregate', path: '/app/syllogos', logo: '/logos/Syllogos.svg', comingSoon: true },
{ name: 'Bibliotheca', description: 'Library', path: '/app/bibliotheca', comingSoon: true },
{ name: 'Dialectica', description: 'Debate', path: '/app/dialectica', comingSoon: true },
diff --git a/src/alex_frontend/src/features/auth/components/ArweaveSettings.tsx b/src/alex_frontend/src/features/auth/components/ArweaveSettings.tsx
new file mode 100644
index 000000000..58bce4f4e
--- /dev/null
+++ b/src/alex_frontend/src/features/auth/components/ArweaveSettings.tsx
@@ -0,0 +1,502 @@
+import React, { useState } from "react";
+import { useFormik } from "formik";
+import * as Yup from "yup";
+import { Button } from "@/lib/components/button";
+import { Input } from "@/lib/components/input";
+import {
+ LoaderCircle,
+ Save,
+ CheckCircle,
+ XCircle,
+ RotateCcw,
+} from "lucide-react";
+import useAuthentication from "@/hooks/actors/useAuthentication";
+import type { ArweaveSettings } from "../../../../../declarations/authentication/authentication.did";
+
+const ArweaveSettingsSchema = Yup.object().shape({
+ uri: Yup.string()
+ .matches(
+ /^https?:\/\/.+/,
+ "URI must be a valid URL (http:// or https://)"
+ )
+ .required("URI is required"),
+ domain: Yup.string()
+ .min(2, "Domain is too short")
+ .required("Domain is required"),
+ statement: Yup.string()
+ .min(10, "Statement is too short")
+ .required("Statement is required"),
+ salt: Yup.string()
+ .min(8, "Salt must be at least 8 characters")
+ .required("Salt is required"),
+ network: Yup.string()
+ .oneOf(["mainnet", "testnet"], "Invalid network")
+ .required("Network is required"),
+ version: Yup.string().required("Version is required"),
+ session_ttl: Yup.number()
+ .min(60, "Session TTL must be at least 60 seconds")
+ .required("Session TTL is required"),
+ message_ttl: Yup.number()
+ .min(60, "Message TTL must be at least 60 seconds")
+ .required("Message TTL is required"),
+});
+
+const ArweaveSettingsComponent = () => {
+ const { actor } = useAuthentication();
+ const [loading, setLoading] = useState(false);
+ const [success, setSuccess] = useState(false);
+ const [error, setError] = useState(null);
+
+ // Default values matching backend defaults
+ const defaultValues = {
+ uri: "http://localhost:8080",
+ domain: "localhost:8080",
+ statement: "Sign in to authenticate with your Arweave wallet",
+ salt: "arweave-mulauth-salt",
+ version: "1",
+ network: "mainnet",
+ session_ttl: 28800, // 8 hours in seconds
+ message_ttl: 600, // 10 minutes in seconds
+ };
+
+ const formik = useFormik({
+ initialValues: defaultValues,
+ validationSchema: ArweaveSettingsSchema,
+ validateOnBlur: true,
+ validateOnChange: true,
+ onSubmit: async (values) => {
+ if (!actor) {
+ setError("Authentication actor not available");
+ return;
+ }
+
+ setLoading(true);
+ setError(null);
+ setSuccess(false);
+
+ try {
+ const settings: ArweaveSettings = {
+ uri: values.uri,
+ domain: values.domain,
+ statement: values.statement,
+ salt: values.salt,
+ network: values.network,
+ version: values.version,
+ session_ttl: BigInt(values.session_ttl * 1_000_000_000), // Convert seconds to nanoseconds
+ message_ttl: BigInt(values.message_ttl * 1_000_000_000), // Convert seconds to nanoseconds
+ };
+
+ const result = await actor.update_arweave_settings(settings);
+
+ if ("Ok" in result) {
+ setSuccess(true);
+ setTimeout(() => setSuccess(false), 3000);
+ } else {
+ setError(result.Err);
+ }
+ } catch (err) {
+ setError(
+ err instanceof Error
+ ? err.message
+ : "Unknown error occurred"
+ );
+ } finally {
+ setLoading(false);
+ }
+ },
+ });
+
+ const resetMessages = () => {
+ setError(null);
+ setSuccess(false);
+ };
+
+ const resetToDefaults = () => {
+ formik.setValues(defaultValues);
+ resetMessages();
+ };
+
+ return (
+
+ {success && (
+
+
+
+ Settings updated successfully
+
+
+ )}
+
+ {error && (
+
+
+ {error}
+
+ )}
+
+
+
+
+ Arweave Authentication
+
+
+ Configure SIWA settings for your application
+
+
+
+
+
+
+ );
+};
+
+export default ArweaveSettingsComponent;
diff --git a/src/alex_frontend/src/features/auth/components/AuthenticationSettings.tsx b/src/alex_frontend/src/features/auth/components/AuthenticationSettings.tsx
new file mode 100644
index 000000000..f8f5edf0f
--- /dev/null
+++ b/src/alex_frontend/src/features/auth/components/AuthenticationSettings.tsx
@@ -0,0 +1,93 @@
+import React from "react";
+import {
+ Tabs,
+ TabsContent,
+ TabsList,
+ TabsTrigger,
+} from "@/lib/components/tabs";
+import { Shield, Settings } from "lucide-react";
+import EthereumSettingsComponent from "./EthereumSettings";
+import SolanaSettingsComponent from "./SolanaSettings";
+import ArweaveSettingsComponent from "./ArweaveSettings";
+import OisySettingsComponent from "./OisySettings";
+
+const AuthenticationSettings = () => {
+ return (
+
+
+
+
+
+ Authentication Provider Settings
+
+
+ Configure authentication settings for each different providers
+
+
+
+
+
+
+
+
+ Ethereum
+
+
+
+ Solana
+
+
+
+ Arweave
+
+
+
+ Oisy
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default AuthenticationSettings;
diff --git a/src/alex_frontend/src/features/auth/components/EthereumSettings.tsx b/src/alex_frontend/src/features/auth/components/EthereumSettings.tsx
new file mode 100644
index 000000000..c2b413f89
--- /dev/null
+++ b/src/alex_frontend/src/features/auth/components/EthereumSettings.tsx
@@ -0,0 +1,500 @@
+import React, { useState } from "react";
+import { useFormik } from "formik";
+import * as Yup from "yup";
+import { Button } from "@/lib/components/button";
+import { Input } from "@/lib/components/input";
+import {
+ LoaderCircle,
+ Save,
+ CheckCircle,
+ XCircle,
+ RotateCcw,
+} from "lucide-react";
+import useAuthentication from "@/hooks/actors/useAuthentication";
+import type { EthereumSettings } from "../../../../../declarations/authentication/authentication.did";
+
+const EthereumSettingsSchema = Yup.object().shape({
+ uri: Yup.string()
+ .matches(
+ /^https?:\/\/.+/,
+ "URI must be a valid URL (http:// or https://)"
+ )
+ .required("URI is required"),
+ domain: Yup.string()
+ .min(2, "Domain is too short")
+ .required("Domain is required"),
+ statement: Yup.string()
+ .min(10, "Statement is too short")
+ .required("Statement is required"),
+ salt: Yup.string()
+ .min(8, "Salt must be at least 8 characters")
+ .required("Salt is required"),
+ version: Yup.string().required("Version is required"),
+ session_ttl: Yup.number()
+ .min(60, "Session TTL must be at least 60 seconds")
+ .required("Session TTL is required"),
+ message_ttl: Yup.number()
+ .min(60, "Message TTL must be at least 60 seconds")
+ .required("Message TTL is required"),
+ chain_id: Yup.number()
+ .min(1, "Chain ID must be positive")
+ .required("Chain ID is required"),
+});
+
+const EthereumSettingsComponent = () => {
+ const { actor } = useAuthentication();
+ const [loading, setLoading] = useState(false);
+ const [success, setSuccess] = useState(false);
+ const [error, setError] = useState(null);
+
+ // Default values matching backend defaults
+ const defaultValues = {
+ uri: "http://localhost:8080",
+ domain: "localhost:8080",
+ statement: "Sign in to authenticate with your Ethereum wallet",
+ salt: "ethereum-mulauth-salt",
+ version: "1",
+ chain_id: 1, // Ethereum mainnet
+ session_ttl: 28800, // 8 hours in seconds
+ message_ttl: 600, // 10 minutes in seconds
+ };
+
+ const formik = useFormik({
+ initialValues: defaultValues,
+ validationSchema: EthereumSettingsSchema,
+ validateOnBlur: true,
+ validateOnChange: true,
+ onSubmit: async (values) => {
+ if (!actor) {
+ setError("Authentication actor not available");
+ return;
+ }
+
+ setLoading(true);
+ setError(null);
+ setSuccess(false);
+
+ try {
+ const settings: EthereumSettings = {
+ uri: values.uri,
+ domain: values.domain,
+ statement: values.statement,
+ salt: values.salt,
+ version: values.version,
+ session_ttl: BigInt(values.session_ttl * 1_000_000_000), // Convert seconds to nanoseconds
+ message_ttl: BigInt(values.message_ttl * 1_000_000_000), // Convert seconds to nanoseconds
+ chain_id: BigInt(values.chain_id),
+ };
+
+ const result = await actor.update_ethereum_settings(settings);
+
+ if ("Ok" in result) {
+ setSuccess(true);
+ setTimeout(() => setSuccess(false), 3000);
+ } else {
+ setError(result.Err);
+ }
+ } catch (err) {
+ setError(
+ err instanceof Error
+ ? err.message
+ : "Unknown error occurred"
+ );
+ } finally {
+ setLoading(false);
+ }
+ },
+ });
+
+ const resetMessages = () => {
+ setError(null);
+ setSuccess(false);
+ };
+
+ const resetToDefaults = () => {
+ formik.setValues(defaultValues);
+ resetMessages();
+ };
+
+ return (
+
+ {success && (
+
+
+
+ Settings updated successfully
+
+
+ )}
+
+ {error && (
+
+
+ {error}
+
+ )}
+
+
+
+
+ Ethereum Authentication
+
+
+ Configure SIWE settings for your application
+
+
+
+
+
+
+ );
+};
+
+export default EthereumSettingsComponent;
diff --git a/src/alex_frontend/src/features/auth/components/OisySettings.tsx b/src/alex_frontend/src/features/auth/components/OisySettings.tsx
new file mode 100644
index 000000000..a79ba3505
--- /dev/null
+++ b/src/alex_frontend/src/features/auth/components/OisySettings.tsx
@@ -0,0 +1,408 @@
+import React, { useState } from "react";
+import { useFormik } from "formik";
+import * as Yup from "yup";
+import { Button } from "@/lib/components/button";
+import { Input } from "@/lib/components/input";
+import {
+ LoaderCircle,
+ Save,
+ CheckCircle,
+ XCircle,
+ RotateCcw,
+} from "lucide-react";
+import useAuthentication from "@/hooks/actors/useAuthentication";
+import type { OisySettings } from "../../../../../declarations/authentication/authentication.did";
+
+const OisySettingsSchema = Yup.object().shape({
+ uri: Yup.string()
+ .matches(
+ /^https?:\/\/.+/,
+ "URI must be a valid URL (http:// or https://)"
+ )
+ .required("URI is required"),
+ domain: Yup.string()
+ .min(2, "Domain is too short")
+ .required("Domain is required"),
+ statement: Yup.string()
+ .min(10, "Statement is too short")
+ .required("Statement is required"),
+ salt: Yup.string()
+ .min(8, "Salt must be at least 8 characters")
+ .required("Salt is required"),
+ version: Yup.string().required("Version is required"),
+ session_ttl: Yup.number()
+ .min(60, "Session TTL must be at least 60 seconds")
+ .required("Session TTL is required"),
+});
+
+const OisySettingsComponent = () => {
+ const { actor } = useAuthentication();
+ const [loading, setLoading] = useState(false);
+ const [success, setSuccess] = useState(false);
+ const [error, setError] = useState(null);
+
+ // Default values matching backend defaults
+ const defaultValues = {
+ uri: "https://oisy.app",
+ domain: "oisy.app",
+ statement: "Sign in with Oisy",
+ salt: "oisy-mulauth-salt",
+ version: "1",
+ session_ttl: 28800, // 8 hours in seconds
+ };
+
+ const formik = useFormik({
+ initialValues: defaultValues,
+ validationSchema: OisySettingsSchema,
+ validateOnBlur: true,
+ validateOnChange: true,
+ onSubmit: async (values) => {
+ if (!actor) {
+ setError("Authentication actor not available");
+ return;
+ }
+
+ setLoading(true);
+ setError(null);
+ setSuccess(false);
+
+ try {
+ const settings: OisySettings = {
+ uri: values.uri,
+ domain: values.domain,
+ statement: values.statement,
+ salt: values.salt,
+ version: values.version,
+ session_ttl: BigInt(values.session_ttl * 1_000_000_000), // Convert seconds to nanoseconds
+ };
+
+ const result = await actor.update_oisy_settings(settings);
+
+ if ("Ok" in result) {
+ setSuccess(true);
+ setTimeout(() => setSuccess(false), 3000);
+ } else {
+ setError(result.Err);
+ }
+ } catch (err) {
+ setError(
+ err instanceof Error
+ ? err.message
+ : "Unknown error occurred"
+ );
+ } finally {
+ setLoading(false);
+ }
+ },
+ });
+
+ const resetMessages = () => {
+ setError(null);
+ setSuccess(false);
+ };
+
+ const resetToDefaults = () => {
+ formik.setValues(defaultValues);
+ resetMessages();
+ };
+
+ return (
+
+ {success && (
+
+
+
+ Settings updated successfully
+
+
+ )}
+
+ {error && (
+
+
+ {error}
+
+ )}
+
+
+
+
+ Oisy Authentication
+
+
+ Configure ICRC-21 settings for your application
+
+
+
+
+
+
+ );
+};
+
+export default OisySettingsComponent;
diff --git a/src/alex_frontend/src/features/auth/components/SolanaSettings.tsx b/src/alex_frontend/src/features/auth/components/SolanaSettings.tsx
new file mode 100644
index 000000000..536a6f0c7
--- /dev/null
+++ b/src/alex_frontend/src/features/auth/components/SolanaSettings.tsx
@@ -0,0 +1,505 @@
+import React, { useState } from "react";
+import { useFormik } from "formik";
+import * as Yup from "yup";
+import { Button } from "@/lib/components/button";
+import { Input } from "@/lib/components/input";
+import {
+ LoaderCircle,
+ Save,
+ CheckCircle,
+ XCircle,
+ RotateCcw,
+} from "lucide-react";
+import useAuthentication from "@/hooks/actors/useAuthentication";
+import type { SolanaSettings } from "../../../../../declarations/authentication/authentication.did";
+
+const SolanaSettingsSchema = Yup.object().shape({
+ uri: Yup.string()
+ .matches(
+ /^https?:\/\/.+/,
+ "URI must be a valid URL (http:// or https://)"
+ )
+ .required("URI is required"),
+ domain: Yup.string()
+ .min(2, "Domain is too short")
+ .required("Domain is required"),
+ statement: Yup.string()
+ .min(10, "Statement is too short")
+ .required("Statement is required"),
+ salt: Yup.string()
+ .min(8, "Salt must be at least 8 characters")
+ .required("Salt is required"),
+ version: Yup.string().required("Version is required"),
+ session_ttl: Yup.number()
+ .min(60, "Session TTL must be at least 60 seconds")
+ .required("Session TTL is required"),
+ message_ttl: Yup.number()
+ .min(60, "Message TTL must be at least 60 seconds")
+ .required("Message TTL is required"),
+ cluster: Yup.string()
+ .oneOf(["mainnet-beta", "devnet", "testnet"], "Invalid cluster")
+ .required("Cluster is required"),
+});
+
+const SolanaSettingsComponent = () => {
+ const { actor } = useAuthentication();
+ const [loading, setLoading] = useState(false);
+ const [success, setSuccess] = useState(false);
+ const [error, setError] = useState(null);
+
+ // Default values matching backend defaults
+ const defaultValues = {
+ uri: "http://localhost:8080",
+ domain: "localhost:8080",
+ statement: "Sign in to authenticate with your Solana wallet",
+ salt: "solana-mulauth-salt",
+ version: "1",
+ cluster: "mainnet-beta",
+ session_ttl: 28800, // 8 hours in seconds
+ message_ttl: 600, // 10 minutes in seconds
+ };
+
+ const formik = useFormik({
+ initialValues: defaultValues,
+ validationSchema: SolanaSettingsSchema,
+ validateOnBlur: true,
+ validateOnChange: true,
+ onSubmit: async (values) => {
+ if (!actor) {
+ setError("Authentication actor not available");
+ return;
+ }
+
+ setLoading(true);
+ setError(null);
+ setSuccess(false);
+
+ try {
+ const settings: SolanaSettings = {
+ uri: values.uri,
+ domain: values.domain,
+ statement: values.statement,
+ salt: values.salt,
+ version: values.version,
+ session_ttl: BigInt(values.session_ttl * 1_000_000_000), // Convert seconds to nanoseconds
+ message_ttl: BigInt(values.message_ttl * 1_000_000_000), // Convert seconds to nanoseconds
+ cluster: values.cluster,
+ };
+
+ const result = await actor.update_solana_settings(settings);
+
+ if ("Ok" in result) {
+ setSuccess(true);
+ setTimeout(() => setSuccess(false), 3000);
+ } else {
+ setError(result.Err);
+ }
+ } catch (err) {
+ setError(
+ err instanceof Error
+ ? err.message
+ : "Unknown error occurred"
+ );
+ } finally {
+ setLoading(false);
+ }
+ },
+ });
+
+ const resetMessages = () => {
+ setError(null);
+ setSuccess(false);
+ };
+
+ const resetToDefaults = () => {
+ formik.setValues(defaultValues);
+ resetMessages();
+ };
+
+ return (
+
+ {success && (
+
+
+
+ Settings updated successfully
+
+
+ )}
+
+ {error && (
+
+
+ {error}
+
+ )}
+
+
+
+
+ Solana Authentication
+
+
+ Configure SIWS settings for your application
+
+
+
+
+
+
+ );
+};
+
+export default SolanaSettingsComponent;
diff --git a/src/alex_frontend/src/features/sonora/components/AudioCard.tsx b/src/alex_frontend/src/features/sonora/components/AudioCard.tsx
new file mode 100644
index 000000000..f0c95be03
--- /dev/null
+++ b/src/alex_frontend/src/features/sonora/components/AudioCard.tsx
@@ -0,0 +1,101 @@
+import React from "react";
+import { FileAudio, HardDrive, Calendar } from "lucide-react";
+import { useAppDispatch } from "@/store/hooks/useAppDispatch";
+import { useAppSelector } from "@/store/hooks/useAppSelector";
+import { setSelected } from "../sonoraSlice";
+import { Audio } from "../types";
+
+interface AudioCardProps {
+ item: Audio;
+ actions?: React.ReactNode;
+}
+
+export const AudioCard: React.FC = ({ item, actions }) => {
+ const dispatch = useAppDispatch();
+ const { selected } = useAppSelector((state) => state.sonora);
+
+ const isSelected = selected?.id === item.id;
+
+ const handleSelect = () => {
+ if (selected?.id === item.id) return;
+ dispatch(setSelected(item));
+ };
+
+ const formatDate = (timestamp: string) => {
+ return new Date(timestamp).toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit'
+ });
+ };
+
+ const getFormatColor = (type: string) => {
+ const colors: Record = {
+ 'audio/mp3': 'bg-blue-100 text-blue-800',
+ 'audio/wav': 'bg-green-100 text-green-800',
+ 'audio/flac': 'bg-purple-100 text-purple-800',
+ 'audio/ogg': 'bg-orange-100 text-orange-800',
+ 'audio/m4a': 'bg-indigo-100 text-indigo-800'
+ };
+ return colors[type] || 'bg-gray-100 text-gray-800';
+ };
+
+ const getFormatLabel = (type: string) => {
+ return type.replace('audio/', '').toUpperCase();
+ };
+
+ return (
+
+
+ {/* Icon */}
+
+
+
+
+ {/* Main Info */}
+
+
+
+ {item.id}
+
+
+ {getFormatLabel(item.type)}
+
+
+
+
+
+
+ {item.size}
+
+
+
+ {formatDate(item.timestamp)}
+
+
+
+
+ {/* Action Buttons */}
+
+ {actions}
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/alex_frontend/src/features/sonora/components/BuyButton.tsx b/src/alex_frontend/src/features/sonora/components/BuyButton.tsx
new file mode 100644
index 000000000..2ed250dfe
--- /dev/null
+++ b/src/alex_frontend/src/features/sonora/components/BuyButton.tsx
@@ -0,0 +1,23 @@
+import React from "react";
+import { ShoppingCart } from "lucide-react";
+import { Audio } from "../types";
+
+interface BuyButtonProps {
+ item?: Audio;
+}
+
+export const BuyButton: React.FC = ({ item }) => {
+ const handleBuy = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ alert("Buy functionality coming soon!");
+ };
+
+ return (
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/alex_frontend/src/features/sonora/components/EditButton.tsx b/src/alex_frontend/src/features/sonora/components/EditButton.tsx
new file mode 100644
index 000000000..7d1a54526
--- /dev/null
+++ b/src/alex_frontend/src/features/sonora/components/EditButton.tsx
@@ -0,0 +1,23 @@
+import React from "react";
+import { Edit } from "lucide-react";
+import { Audio } from "../types";
+
+interface EditButtonProps {
+ item?: Audio;
+}
+
+export const EditButton: React.FC = ({ item }) => {
+ const handleEdit = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ alert("Edit functionality coming soon!");
+ };
+
+ return (
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/alex_frontend/src/features/sonora/components/MintButton.tsx b/src/alex_frontend/src/features/sonora/components/MintButton.tsx
new file mode 100644
index 000000000..bc1bce78d
--- /dev/null
+++ b/src/alex_frontend/src/features/sonora/components/MintButton.tsx
@@ -0,0 +1,23 @@
+import React from "react";
+import { Coins } from "lucide-react";
+import { Audio } from "../types";
+
+interface MintButtonProps {
+ item?: Audio;
+}
+
+export const MintButton: React.FC = ({ item }) => {
+ const handleMint = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ alert("Mint functionality coming soon!");
+ };
+
+ return (
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/alex_frontend/src/features/sonora/components/PlayPauseButton.tsx b/src/alex_frontend/src/features/sonora/components/PlayPauseButton.tsx
new file mode 100644
index 000000000..937eebbdf
--- /dev/null
+++ b/src/alex_frontend/src/features/sonora/components/PlayPauseButton.tsx
@@ -0,0 +1,50 @@
+import React from "react";
+import { Play, Pause } from "lucide-react";
+import { useAppDispatch } from "@/store/hooks/useAppDispatch";
+import { useAppSelector } from "@/store/hooks/useAppSelector";
+import { playAudio, pauseAudio } from "../sonoraSlice";
+import { Audio } from "../types";
+
+interface PlayPauseButtonProps {
+ item: Audio;
+}
+
+export const PlayPauseButton: React.FC = ({ item }) => {
+ const dispatch = useAppDispatch();
+ const { selected, playing } = useAppSelector((state) => state.sonora);
+
+ const isSelected = selected?.id === item.id;
+ const isPlaying = playing && isSelected;
+
+ const handlePlay = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ dispatch(playAudio(item));
+ };
+
+ const handlePause = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ dispatch(pauseAudio());
+ };
+
+ return (
+
+ {isPlaying ? (
+
+ ) : (
+
+ )}
+
+ );
+};
\ No newline at end of file
diff --git a/src/alex_frontend/src/features/sonora/components/SellButton.tsx b/src/alex_frontend/src/features/sonora/components/SellButton.tsx
new file mode 100644
index 000000000..2ea385ffe
--- /dev/null
+++ b/src/alex_frontend/src/features/sonora/components/SellButton.tsx
@@ -0,0 +1,23 @@
+import React from "react";
+import { DollarSign } from "lucide-react";
+import { Audio } from "../types";
+
+interface SellButtonProps {
+ item?: Audio;
+}
+
+export const SellButton: React.FC = ({ item }) => {
+ const handleSell = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ alert("Sell functionality coming soon!");
+ };
+
+ return (
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/alex_frontend/src/features/sonora/components/UnlistButton.tsx b/src/alex_frontend/src/features/sonora/components/UnlistButton.tsx
new file mode 100644
index 000000000..e95e36133
--- /dev/null
+++ b/src/alex_frontend/src/features/sonora/components/UnlistButton.tsx
@@ -0,0 +1,23 @@
+import React from "react";
+import { X } from "lucide-react";
+import { Audio } from "../types";
+
+interface UnlistButtonProps {
+ item?: Audio;
+}
+
+export const UnlistButton: React.FC = ({ item }) => {
+ const handleUnlist = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ alert("Unlist functionality coming soon!");
+ };
+
+ return (
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/alex_frontend/src/features/sonora/data.ts b/src/alex_frontend/src/features/sonora/data.ts
new file mode 100644
index 000000000..70c54cafb
--- /dev/null
+++ b/src/alex_frontend/src/features/sonora/data.ts
@@ -0,0 +1,124 @@
+import { Audio } from "./types";
+
+export const mockAudio: Audio[] = [
+ {
+ id: "7xK9mR3nP8qW5vL2",
+ type: "audio/mp3",
+ size: "12.4 MB",
+ timestamp: "2024-11-03T14:32:15Z"
+ },
+ {
+ id: "2nQ8fT6kL9mX4wR7",
+ type: "audio/wav",
+ size: "45.8 MB",
+ timestamp: "2024-11-03T11:18:44Z"
+ },
+ {
+ id: "9pV5nK2tW8qR3mL6",
+ type: "audio/flac",
+ size: "38.2 MB",
+ timestamp: "2024-11-03T09:45:22Z"
+ },
+ {
+ id: "4xM7qR9nL3wP6vK2",
+ type: "audio/mp3",
+ size: "8.9 MB",
+ timestamp: "2024-11-02T16:27:33Z"
+ },
+ {
+ id: "6nT8rK4mQ9wX2vL5",
+ type: "audio/ogg",
+ size: "7.3 MB",
+ timestamp: "2024-11-02T13:52:18Z"
+ },
+ {
+ id: "1wP5nR8qK3mT7xL9",
+ type: "audio/wav",
+ size: "52.1 MB",
+ timestamp: "2024-11-02T10:14:09Z"
+ },
+ {
+ id: "3mL6qT9nR5wK8xP2",
+ type: "audio/m4a",
+ size: "9.7 MB",
+ timestamp: "2024-11-01T15:39:51Z"
+ },
+ {
+ id: "8xR2nK5mQ7wP4vT9",
+ type: "audio/mp3",
+ size: "15.6 MB",
+ timestamp: "2024-11-01T12:06:27Z"
+ },
+ {
+ id: "5nR9pK7mT3wQ8xL4",
+ type: "audio/flac",
+ size: "42.3 MB",
+ timestamp: "2024-10-31T18:25:33Z"
+ },
+ {
+ id: "7mQ2nK9rL5wP6xT8",
+ type: "audio/mp3",
+ size: "11.2 MB",
+ timestamp: "2024-10-31T14:17:48Z"
+ },
+ {
+ id: "3wK8nR4mQ7pL9xT5",
+ type: "audio/ogg",
+ size: "6.8 MB",
+ timestamp: "2024-10-30T21:44:12Z"
+ },
+ {
+ id: "9xT4nK7mR2wQ5pL8",
+ type: "audio/wav",
+ size: "38.9 MB",
+ timestamp: "2024-10-30T16:33:27Z"
+ },
+ {
+ id: "6pL3nR8qK4mT7xW9",
+ type: "audio/m4a",
+ size: "13.5 MB",
+ timestamp: "2024-10-29T19:56:41Z"
+ },
+ {
+ id: "2mT5nQ9rK7wP3xL6",
+ type: "audio/mp3",
+ size: "9.3 MB",
+ timestamp: "2024-10-29T13:28:55Z"
+ },
+ {
+ id: "8wR6nK2mQ4pL7xT9",
+ type: "audio/flac",
+ size: "51.7 MB",
+ timestamp: "2024-10-28T20:15:09Z"
+ },
+ {
+ id: "4xL9nT3mR6wK8pQ5",
+ type: "audio/wav",
+ size: "29.4 MB",
+ timestamp: "2024-10-28T11:42:37Z"
+ },
+ {
+ id: "7pQ2nK5mT8wR4xL9",
+ type: "audio/ogg",
+ size: "8.1 MB",
+ timestamp: "2024-10-27T15:19:23Z"
+ },
+ {
+ id: "1mR7nL4qK9wT6xP3",
+ type: "audio/mp3",
+ size: "14.8 MB",
+ timestamp: "2024-10-27T09:37:14Z"
+ },
+ {
+ id: "5xK3nR8mQ2wL7pT9",
+ type: "audio/m4a",
+ size: "10.6 MB",
+ timestamp: "2024-10-26T17:53:46Z"
+ },
+ {
+ id: "9wT6nK4mR7qL2xP8",
+ type: "audio/wav",
+ size: "33.2 MB",
+ timestamp: "2024-10-26T12:08:59Z"
+ }
+];
\ No newline at end of file
diff --git a/src/alex_frontend/src/features/sonora/sonoraSlice.ts b/src/alex_frontend/src/features/sonora/sonoraSlice.ts
new file mode 100644
index 000000000..3b9bd8d06
--- /dev/null
+++ b/src/alex_frontend/src/features/sonora/sonoraSlice.ts
@@ -0,0 +1,42 @@
+import { createSlice, PayloadAction } from "@reduxjs/toolkit";
+import { Audio, SonoraState } from "./types";
+
+const initialState: SonoraState = {
+ selected: null,
+ playing: false,
+};
+
+const sonoraSlice = createSlice({
+ name: "sonora",
+ initialState,
+ reducers: {
+ setSelected: (state, action: PayloadAction