diff --git a/src/alex_frontend/core/config/apps.ts b/src/alex_frontend/core/config/apps.ts index 0fbcd12ab..003625415 100644 --- a/src/alex_frontend/core/config/apps.ts +++ b/src/alex_frontend/core/config/apps.ts @@ -13,7 +13,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: 'Sonora', description: 'Audio', path: '/app/sonora', logo: '/logos/Sonora.svg' }, { 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/core/features/sonora/components/AudioCard.tsx b/src/alex_frontend/core/features/sonora/components/AudioCard.tsx index 814301e35..368319d9a 100644 --- a/src/alex_frontend/core/features/sonora/components/AudioCard.tsx +++ b/src/alex_frontend/core/features/sonora/components/AudioCard.tsx @@ -1,9 +1,16 @@ import React, { useRef, useState, useEffect } from "react"; import { FileAudio, HardDrive, Calendar, Play, Pause, Loader2, DollarSign, User } from "lucide-react"; +import { Link } from "@tanstack/react-router"; import { useAppDispatch } from "@/store/hooks/useAppDispatch"; import { useAppSelector } from "@/store/hooks/useAppSelector"; import { setSelected, clearSelected } from "../sonoraSlice"; import { Audio } from "../types"; +import { shortenPrincipal } from "@/features/perpetua"; + +const ownerLinks = [ + { to: "/app/sonora/studio/$principal", label: "listings" }, + { to: "/app/sonora/archive/$principal", label: "archive" }, +] as const; interface AudioCardProps { item: Audio; @@ -158,15 +165,6 @@ export const AudioCard: React.FC = ({ item, actions, price, owne return type || 'No Type'; }; - const formatOwner = (ownerPrincipal: string) => { - if (!ownerPrincipal) return 'Unknown'; - // Show first 6 and last 4 characters with ellipsis - if (ownerPrincipal.length > 12) { - return `${ownerPrincipal.slice(0, 6)}...${ownerPrincipal.slice(-4)}`; - } - return ownerPrincipal; - }; - return (
= ({ item, actions, price, owne {owner && (
- {formatOwner(owner)} + {shortenPrincipal(owner)} + {ownerLinks.map((link) => ( + + · + e.stopPropagation()} + className="text-primary hover:underline" + > + {link.label} + + + ))}
)} {isSelected && duration > 0 && ( diff --git a/src/alex_frontend/core/features/sonora/hooks/useMarketAudioNFTs.ts b/src/alex_frontend/core/features/sonora/hooks/useMarketAudioNFTs.ts index 3f380dee8..3419093e8 100644 --- a/src/alex_frontend/core/features/sonora/hooks/useMarketAudioNFTs.ts +++ b/src/alex_frontend/core/features/sonora/hooks/useMarketAudioNFTs.ts @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useCallback } from "react"; import { useAppSelector } from "@/store/hooks/useAppSelector"; import { useAppDispatch } from "@/store/hooks/useAppDispatch"; import { fetchMarketAudioNFTs } from "../marketSlice"; @@ -9,14 +9,14 @@ export const useMarketAudioNFTs = () => { const user = useAppSelector((state) => state.auth.user); const { audios, loading, loadingMore, error, pagination } = useAppSelector((state) => state.sonora.market); - const refreshMarketAudioNFTs = (page = 1, pageSize = 8, appendMode = false) => { - dispatch(fetchMarketAudioNFTs({ - page, - pageSize, - appendMode, - currentUserPrincipal: user?.principal + const refreshMarketAudioNFTs = useCallback((page = 1, pageSize = 8, appendMode = false) => { + dispatch(fetchMarketAudioNFTs({ + page, + pageSize, + appendMode, + currentUserPrincipal: user?.principal })); - }; + }, [dispatch, user?.principal]); return { audios, diff --git a/src/alex_frontend/core/features/sonora/hooks/useSonoraProfileTarget.ts b/src/alex_frontend/core/features/sonora/hooks/useSonoraProfileTarget.ts new file mode 100644 index 000000000..c299e049b --- /dev/null +++ b/src/alex_frontend/core/features/sonora/hooks/useSonoraProfileTarget.ts @@ -0,0 +1,10 @@ +import { useParams } from "@tanstack/react-router"; +import { useAppSelector } from "@/store/hooks/useAppSelector"; + +export const useSonoraProfileTarget = () => { + const { user } = useAppSelector((state) => state.auth); + const { principal } = useParams({ strict: false }); + const targetPrincipal = principal ?? user?.principal; + const isOwner = !principal || principal === user?.principal; + return { targetPrincipal, isOwner }; +}; diff --git a/src/alex_frontend/core/features/sonora/hooks/useStudioAudioNFTs.ts b/src/alex_frontend/core/features/sonora/hooks/useStudioAudioNFTs.ts index 77d65acdb..08d2228dd 100644 --- a/src/alex_frontend/core/features/sonora/hooks/useStudioAudioNFTs.ts +++ b/src/alex_frontend/core/features/sonora/hooks/useStudioAudioNFTs.ts @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useCallback } from "react"; import { useAppSelector } from "@/store/hooks/useAppSelector"; import { useAppDispatch } from "@/store/hooks/useAppDispatch"; import { fetchStudioAudioNFTs } from "../studioSlice"; @@ -8,9 +8,9 @@ export const useStudioAudioNFTs = () => { const dispatch = useAppDispatch(); const { audios, loading, loadingMore, error, pagination } = useAppSelector((state) => state.sonora.studio); - const refreshStudioAudioNFTs = (userPrincipal: string, page = 1, pageSize = 8, appendMode = false) => { + const refreshStudioAudioNFTs = useCallback((userPrincipal: string, page = 1, pageSize = 8, appendMode = false) => { dispatch(fetchStudioAudioNFTs({ userPrincipal, page, pageSize, appendMode })); - }; + }, [dispatch]); return { audios, diff --git a/src/alex_frontend/core/layouts/SonoraLayout.tsx b/src/alex_frontend/core/layouts/SonoraLayout.tsx new file mode 100644 index 000000000..edac00743 --- /dev/null +++ b/src/alex_frontend/core/layouts/SonoraLayout.tsx @@ -0,0 +1,48 @@ +import React from "react"; +import { Link, Outlet } from "@tanstack/react-router"; + +const tabs = [ + { to: "/app/sonora", label: "Browse", exact: true }, + { to: "/app/sonora/record", label: "Record" }, + { to: "/app/sonora/archive", label: "Archive" }, + { to: "/app/sonora/studio", label: "Studio" }, + { to: "/app/sonora/market", label: "Market" }, +] as const; + +const tabClass = "px-6 py-2 rounded-bordertb font-medium text-tabsheading transition-colors duration-200"; +const activeProps = { className: "bg-primary text-primary-foreground shadow-md" }; +const inactiveProps = { className: "bg-transparent text-muted-foreground hover:bg-muted hover:text-primary" }; + +function SonoraLayout() { + return ( + <> +
+
+

Sonora

+

Discover, Record, and Trade Audio NFTs

+
+
+ +
+
+
+ +
+ + ); +} + +export default SonoraLayout; diff --git a/src/alex_frontend/lbry/public/logos/Sonora.svg b/src/alex_frontend/lbry/public/logos/Sonora.svg new file mode 100644 index 000000000..894e194a5 --- /dev/null +++ b/src/alex_frontend/lbry/public/logos/Sonora.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/alex_frontend/lbry/routeTree.gen.ts b/src/alex_frontend/lbry/routeTree.gen.ts index c07071a6a..489519024 100644 --- a/src/alex_frontend/lbry/routeTree.gen.ts +++ b/src/alex_frontend/lbry/routeTree.gen.ts @@ -19,9 +19,17 @@ import { Route as SwapInsightsRouteImport } from './src/routes/swap/insights' import { Route as NftTokenIdRouteImport } from './src/routes/nft.$tokenId' import { Route as AppPermasearchRouteImport } from './src/routes/app/permasearch' import { Route as AppAlexandrianRouteImport } from './src/routes/app/alexandrian' +import { Route as AuthAppSonoraIndexRouteImport } from './src/routes/_auth/app/sonora/index' import { Route as AuthAppEmporiumIndexRouteImport } from './src/routes/_auth/app/emporium/index' +import { Route as AuthAppSonoraUploadRouteImport } from './src/routes/_auth/app/sonora/upload' +import { Route as AuthAppSonoraStudioRouteImport } from './src/routes/_auth/app/sonora/studio' +import { Route as AuthAppSonoraRecordRouteImport } from './src/routes/_auth/app/sonora/record' +import { Route as AuthAppSonoraMarketRouteImport } from './src/routes/_auth/app/sonora/market' +import { Route as AuthAppSonoraArchiveRouteImport } from './src/routes/_auth/app/sonora/archive' import { Route as AuthAppEmporiumMyLogsRouteImport } from './src/routes/_auth/app/emporium/my-logs' import { Route as AuthAppEmporiumMarketLogsRouteImport } from './src/routes/_auth/app/emporium/market-logs' +import { Route as AuthAppSonoraStudioPrincipalRouteImport } from './src/routes/_auth/app/sonora/studio.$principal' +import { Route as AuthAppSonoraArchivePrincipalRouteImport } from './src/routes/_auth/app/sonora/archive.$principal' const ManagerLazyRouteImport = createFileRoute('/manager')() const SwapRouteLazyRouteImport = createFileRoute('/swap')() @@ -54,6 +62,7 @@ const AuthAppPinaxLazyRouteImport = createFileRoute('/_auth/app/pinax')() const AuthDashboardLibrarianRouteLazyRouteImport = createFileRoute( '/_auth/dashboard/_librarian', )() +const AuthAppSonoraRouteLazyRouteImport = createFileRoute('/_auth/app/sonora')() const AuthAppEmporiumRouteLazyRouteImport = createFileRoute( '/_auth/app/emporium', )() @@ -281,6 +290,13 @@ const AuthDashboardLibrarianRouteLazyRoute = (d) => d.Route, ), ) +const AuthAppSonoraRouteLazyRoute = AuthAppSonoraRouteLazyRouteImport.update({ + id: '/app/sonora', + path: '/app/sonora', + getParentRoute: () => AuthRouteLazyRoute, +} as any).lazy(() => + import('./src/routes/_auth/app/sonora/route.lazy').then((d) => d.Route), +) const AuthAppEmporiumRouteLazyRoute = AuthAppEmporiumRouteLazyRouteImport.update({ id: '/app/emporium', @@ -307,6 +323,13 @@ const AuthAppPerpetuaIndexLazyRoute = } as any).lazy(() => import('./src/routes/_auth/app/perpetua/index.lazy').then((d) => d.Route), ) +const AuthAppSonoraIndexRoute = AuthAppSonoraIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => AuthAppSonoraRouteLazyRoute, +} as any).lazy(() => + import('./src/routes/_auth/app/sonora/index.lazy').then((d) => d.Route), +) const AuthAppEmporiumIndexRoute = AuthAppEmporiumIndexRouteImport.update({ id: '/', path: '/', @@ -334,6 +357,41 @@ const AuthDashboardLibrarianWalletsLazyRoute = (d) => d.Route, ), ) +const AuthAppSonoraUploadRoute = AuthAppSonoraUploadRouteImport.update({ + id: '/upload', + path: '/upload', + getParentRoute: () => AuthAppSonoraRouteLazyRoute, +} as any).lazy(() => + import('./src/routes/_auth/app/sonora/upload.lazy').then((d) => d.Route), +) +const AuthAppSonoraStudioRoute = AuthAppSonoraStudioRouteImport.update({ + id: '/studio', + path: '/studio', + getParentRoute: () => AuthAppSonoraRouteLazyRoute, +} as any).lazy(() => + import('./src/routes/_auth/app/sonora/studio.lazy').then((d) => d.Route), +) +const AuthAppSonoraRecordRoute = AuthAppSonoraRecordRouteImport.update({ + id: '/record', + path: '/record', + getParentRoute: () => AuthAppSonoraRouteLazyRoute, +} as any).lazy(() => + import('./src/routes/_auth/app/sonora/record.lazy').then((d) => d.Route), +) +const AuthAppSonoraMarketRoute = AuthAppSonoraMarketRouteImport.update({ + id: '/market', + path: '/market', + getParentRoute: () => AuthAppSonoraRouteLazyRoute, +} as any).lazy(() => + import('./src/routes/_auth/app/sonora/market.lazy').then((d) => d.Route), +) +const AuthAppSonoraArchiveRoute = AuthAppSonoraArchiveRouteImport.update({ + id: '/archive', + path: '/archive', + getParentRoute: () => AuthAppSonoraRouteLazyRoute, +} as any).lazy(() => + import('./src/routes/_auth/app/sonora/archive.lazy').then((d) => d.Route), +) const AuthAppEmporiumMyLogsRoute = AuthAppEmporiumMyLogsRouteImport.update({ id: '/my-logs', path: '/my-logs', @@ -361,6 +419,26 @@ const AuthAppPerpetuaShelfShelfIdLazyRoute = (d) => d.Route, ), ) +const AuthAppSonoraStudioPrincipalRoute = + AuthAppSonoraStudioPrincipalRouteImport.update({ + id: '/$principal', + path: '/$principal', + getParentRoute: () => AuthAppSonoraStudioRoute, + } as any).lazy(() => + import('./src/routes/_auth/app/sonora/studio.$principal.lazy').then( + (d) => d.Route, + ), + ) +const AuthAppSonoraArchivePrincipalRoute = + AuthAppSonoraArchivePrincipalRouteImport.update({ + id: '/$principal', + path: '/$principal', + getParentRoute: () => AuthAppSonoraArchiveRoute, + } as any).lazy(() => + import('./src/routes/_auth/app/sonora/archive.$principal.lazy').then( + (d) => d.Route, + ), + ) const AuthAppPerpetuaUserUserIdIndexLazyRoute = AuthAppPerpetuaUserUserIdIndexLazyRouteImport.update({ id: '/app/perpetua/user/$userId/', @@ -403,6 +481,7 @@ export interface FileRoutesByFullPath { '/swap/stake': typeof SwapStakeLazyRoute '/info/': typeof InfoIndexLazyRoute '/app/emporium': typeof AuthAppEmporiumRouteLazyRouteWithChildren + '/app/sonora': typeof AuthAppSonoraRouteLazyRouteWithChildren '/app/pinax': typeof AuthAppPinaxLazyRoute '/dashboard/analytics': typeof AuthDashboardAnalyticsLazyRoute '/dashboard/arweave-assets': typeof AuthDashboardArweaveAssetsLazyRoute @@ -412,11 +491,19 @@ export interface FileRoutesByFullPath { '/swap/': typeof AuthSwapIndexLazyRoute '/app/emporium/market-logs': typeof AuthAppEmporiumMarketLogsRoute '/app/emporium/my-logs': typeof AuthAppEmporiumMyLogsRoute + '/app/sonora/archive': typeof AuthAppSonoraArchiveRouteWithChildren + '/app/sonora/market': typeof AuthAppSonoraMarketRoute + '/app/sonora/record': typeof AuthAppSonoraRecordRoute + '/app/sonora/studio': typeof AuthAppSonoraStudioRouteWithChildren + '/app/sonora/upload': typeof AuthAppSonoraUploadRoute '/dashboard/wallets': typeof AuthDashboardLibrarianWalletsLazyRoute '/dashboard/profile/upgrade': typeof AuthDashboardProfileUpgradeLazyRoute '/app/emporium/': typeof AuthAppEmporiumIndexRoute + '/app/sonora/': typeof AuthAppSonoraIndexRoute '/app/perpetua': typeof AuthAppPerpetuaIndexLazyRoute '/dashboard/': typeof AuthDashboardLibrarianIndexLazyRoute + '/app/sonora/archive/$principal': typeof AuthAppSonoraArchivePrincipalRoute + '/app/sonora/studio/$principal': typeof AuthAppSonoraStudioPrincipalRoute '/app/perpetua/shelf/$shelfId': typeof AuthAppPerpetuaShelfShelfIdLazyRoute '/app/perpetua/user/$userId': typeof AuthAppPerpetuaUserUserIdIndexLazyRoute '/app/perpetua/user/$userId/shelf/$shelfId': typeof AuthAppPerpetuaUserUserIdShelfShelfIdLazyRoute @@ -448,10 +535,18 @@ export interface FileRoutesByTo { '/swap/burn': typeof AuthSwapBurnLazyRoute '/app/emporium/market-logs': typeof AuthAppEmporiumMarketLogsRoute '/app/emporium/my-logs': typeof AuthAppEmporiumMyLogsRoute + '/app/sonora/archive': typeof AuthAppSonoraArchiveRouteWithChildren + '/app/sonora/market': typeof AuthAppSonoraMarketRoute + '/app/sonora/record': typeof AuthAppSonoraRecordRoute + '/app/sonora/studio': typeof AuthAppSonoraStudioRouteWithChildren + '/app/sonora/upload': typeof AuthAppSonoraUploadRoute '/dashboard/wallets': typeof AuthDashboardLibrarianWalletsLazyRoute '/dashboard/profile/upgrade': typeof AuthDashboardProfileUpgradeLazyRoute '/app/emporium': typeof AuthAppEmporiumIndexRoute + '/app/sonora': typeof AuthAppSonoraIndexRoute '/app/perpetua': typeof AuthAppPerpetuaIndexLazyRoute + '/app/sonora/archive/$principal': typeof AuthAppSonoraArchivePrincipalRoute + '/app/sonora/studio/$principal': typeof AuthAppSonoraStudioPrincipalRoute '/app/perpetua/shelf/$shelfId': typeof AuthAppPerpetuaShelfShelfIdLazyRoute '/app/perpetua/user/$userId': typeof AuthAppPerpetuaUserUserIdIndexLazyRoute '/app/perpetua/user/$userId/shelf/$shelfId': typeof AuthAppPerpetuaUserUserIdShelfShelfIdLazyRoute @@ -480,6 +575,7 @@ export interface FileRoutesById { '/swap/stake': typeof SwapStakeLazyRoute '/info/': typeof InfoIndexLazyRoute '/_auth/app/emporium': typeof AuthAppEmporiumRouteLazyRouteWithChildren + '/_auth/app/sonora': typeof AuthAppSonoraRouteLazyRouteWithChildren '/_auth/dashboard/_librarian': typeof AuthDashboardLibrarianRouteLazyRouteWithChildren '/_auth/app/pinax': typeof AuthAppPinaxLazyRoute '/_auth/dashboard/analytics': typeof AuthDashboardAnalyticsLazyRoute @@ -490,11 +586,19 @@ export interface FileRoutesById { '/_auth/swap/': typeof AuthSwapIndexLazyRoute '/_auth/app/emporium/market-logs': typeof AuthAppEmporiumMarketLogsRoute '/_auth/app/emporium/my-logs': typeof AuthAppEmporiumMyLogsRoute + '/_auth/app/sonora/archive': typeof AuthAppSonoraArchiveRouteWithChildren + '/_auth/app/sonora/market': typeof AuthAppSonoraMarketRoute + '/_auth/app/sonora/record': typeof AuthAppSonoraRecordRoute + '/_auth/app/sonora/studio': typeof AuthAppSonoraStudioRouteWithChildren + '/_auth/app/sonora/upload': typeof AuthAppSonoraUploadRoute '/_auth/dashboard/_librarian/wallets': typeof AuthDashboardLibrarianWalletsLazyRoute '/_auth/dashboard/profile/upgrade': typeof AuthDashboardProfileUpgradeLazyRoute '/_auth/app/emporium/': typeof AuthAppEmporiumIndexRoute + '/_auth/app/sonora/': typeof AuthAppSonoraIndexRoute '/_auth/app/perpetua/': typeof AuthAppPerpetuaIndexLazyRoute '/_auth/dashboard/_librarian/': typeof AuthDashboardLibrarianIndexLazyRoute + '/_auth/app/sonora/archive/$principal': typeof AuthAppSonoraArchivePrincipalRoute + '/_auth/app/sonora/studio/$principal': typeof AuthAppSonoraStudioPrincipalRoute '/_auth/app/perpetua/shelf/$shelfId': typeof AuthAppPerpetuaShelfShelfIdLazyRoute '/_auth/app/perpetua/user/$userId/': typeof AuthAppPerpetuaUserUserIdIndexLazyRoute '/_auth/app/perpetua/user/$userId/shelf/$shelfId': typeof AuthAppPerpetuaUserUserIdShelfShelfIdLazyRoute @@ -522,6 +626,7 @@ export interface FileRouteTypes { | '/swap/stake' | '/info/' | '/app/emporium' + | '/app/sonora' | '/app/pinax' | '/dashboard/analytics' | '/dashboard/arweave-assets' @@ -531,11 +636,19 @@ export interface FileRouteTypes { | '/swap/' | '/app/emporium/market-logs' | '/app/emporium/my-logs' + | '/app/sonora/archive' + | '/app/sonora/market' + | '/app/sonora/record' + | '/app/sonora/studio' + | '/app/sonora/upload' | '/dashboard/wallets' | '/dashboard/profile/upgrade' | '/app/emporium/' + | '/app/sonora/' | '/app/perpetua' | '/dashboard/' + | '/app/sonora/archive/$principal' + | '/app/sonora/studio/$principal' | '/app/perpetua/shelf/$shelfId' | '/app/perpetua/user/$userId' | '/app/perpetua/user/$userId/shelf/$shelfId' @@ -567,10 +680,18 @@ export interface FileRouteTypes { | '/swap/burn' | '/app/emporium/market-logs' | '/app/emporium/my-logs' + | '/app/sonora/archive' + | '/app/sonora/market' + | '/app/sonora/record' + | '/app/sonora/studio' + | '/app/sonora/upload' | '/dashboard/wallets' | '/dashboard/profile/upgrade' | '/app/emporium' + | '/app/sonora' | '/app/perpetua' + | '/app/sonora/archive/$principal' + | '/app/sonora/studio/$principal' | '/app/perpetua/shelf/$shelfId' | '/app/perpetua/user/$userId' | '/app/perpetua/user/$userId/shelf/$shelfId' @@ -598,6 +719,7 @@ export interface FileRouteTypes { | '/swap/stake' | '/info/' | '/_auth/app/emporium' + | '/_auth/app/sonora' | '/_auth/dashboard/_librarian' | '/_auth/app/pinax' | '/_auth/dashboard/analytics' @@ -608,11 +730,19 @@ export interface FileRouteTypes { | '/_auth/swap/' | '/_auth/app/emporium/market-logs' | '/_auth/app/emporium/my-logs' + | '/_auth/app/sonora/archive' + | '/_auth/app/sonora/market' + | '/_auth/app/sonora/record' + | '/_auth/app/sonora/studio' + | '/_auth/app/sonora/upload' | '/_auth/dashboard/_librarian/wallets' | '/_auth/dashboard/profile/upgrade' | '/_auth/app/emporium/' + | '/_auth/app/sonora/' | '/_auth/app/perpetua/' | '/_auth/dashboard/_librarian/' + | '/_auth/app/sonora/archive/$principal' + | '/_auth/app/sonora/studio/$principal' | '/_auth/app/perpetua/shelf/$shelfId' | '/_auth/app/perpetua/user/$userId/' | '/_auth/app/perpetua/user/$userId/shelf/$shelfId' @@ -837,6 +967,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthDashboardLibrarianRouteLazyRouteImport parentRoute: typeof AuthDashboardRouteLazyRoute } + '/_auth/app/sonora': { + id: '/_auth/app/sonora' + path: '/app/sonora' + fullPath: '/app/sonora' + preLoaderRoute: typeof AuthAppSonoraRouteLazyRouteImport + parentRoute: typeof AuthRouteLazyRoute + } '/_auth/app/emporium': { id: '/_auth/app/emporium' path: '/app/emporium' @@ -858,6 +995,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthAppPerpetuaIndexLazyRouteImport parentRoute: typeof AuthRouteLazyRoute } + '/_auth/app/sonora/': { + id: '/_auth/app/sonora/' + path: '/' + fullPath: '/app/sonora/' + preLoaderRoute: typeof AuthAppSonoraIndexRouteImport + parentRoute: typeof AuthAppSonoraRouteLazyRoute + } '/_auth/app/emporium/': { id: '/_auth/app/emporium/' path: '/' @@ -879,6 +1023,41 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthDashboardLibrarianWalletsLazyRouteImport parentRoute: typeof AuthDashboardLibrarianRouteLazyRoute } + '/_auth/app/sonora/upload': { + id: '/_auth/app/sonora/upload' + path: '/upload' + fullPath: '/app/sonora/upload' + preLoaderRoute: typeof AuthAppSonoraUploadRouteImport + parentRoute: typeof AuthAppSonoraRouteLazyRoute + } + '/_auth/app/sonora/studio': { + id: '/_auth/app/sonora/studio' + path: '/studio' + fullPath: '/app/sonora/studio' + preLoaderRoute: typeof AuthAppSonoraStudioRouteImport + parentRoute: typeof AuthAppSonoraRouteLazyRoute + } + '/_auth/app/sonora/record': { + id: '/_auth/app/sonora/record' + path: '/record' + fullPath: '/app/sonora/record' + preLoaderRoute: typeof AuthAppSonoraRecordRouteImport + parentRoute: typeof AuthAppSonoraRouteLazyRoute + } + '/_auth/app/sonora/market': { + id: '/_auth/app/sonora/market' + path: '/market' + fullPath: '/app/sonora/market' + preLoaderRoute: typeof AuthAppSonoraMarketRouteImport + parentRoute: typeof AuthAppSonoraRouteLazyRoute + } + '/_auth/app/sonora/archive': { + id: '/_auth/app/sonora/archive' + path: '/archive' + fullPath: '/app/sonora/archive' + preLoaderRoute: typeof AuthAppSonoraArchiveRouteImport + parentRoute: typeof AuthAppSonoraRouteLazyRoute + } '/_auth/app/emporium/my-logs': { id: '/_auth/app/emporium/my-logs' path: '/my-logs' @@ -900,6 +1079,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthAppPerpetuaShelfShelfIdLazyRouteImport parentRoute: typeof AuthRouteLazyRoute } + '/_auth/app/sonora/studio/$principal': { + id: '/_auth/app/sonora/studio/$principal' + path: '/$principal' + fullPath: '/app/sonora/studio/$principal' + preLoaderRoute: typeof AuthAppSonoraStudioPrincipalRouteImport + parentRoute: typeof AuthAppSonoraStudioRoute + } + '/_auth/app/sonora/archive/$principal': { + id: '/_auth/app/sonora/archive/$principal' + path: '/$principal' + fullPath: '/app/sonora/archive/$principal' + preLoaderRoute: typeof AuthAppSonoraArchivePrincipalRouteImport + parentRoute: typeof AuthAppSonoraArchiveRoute + } '/_auth/app/perpetua/user/$userId/': { id: '/_auth/app/perpetua/user/$userId/' path: '/app/perpetua/user/$userId' @@ -1002,10 +1195,57 @@ const AuthAppEmporiumRouteLazyRouteWithChildren = AuthAppEmporiumRouteLazyRouteChildren, ) +interface AuthAppSonoraArchiveRouteChildren { + AuthAppSonoraArchivePrincipalRoute: typeof AuthAppSonoraArchivePrincipalRoute +} + +const AuthAppSonoraArchiveRouteChildren: AuthAppSonoraArchiveRouteChildren = { + AuthAppSonoraArchivePrincipalRoute: AuthAppSonoraArchivePrincipalRoute, +} + +const AuthAppSonoraArchiveRouteWithChildren = + AuthAppSonoraArchiveRoute._addFileChildren(AuthAppSonoraArchiveRouteChildren) + +interface AuthAppSonoraStudioRouteChildren { + AuthAppSonoraStudioPrincipalRoute: typeof AuthAppSonoraStudioPrincipalRoute +} + +const AuthAppSonoraStudioRouteChildren: AuthAppSonoraStudioRouteChildren = { + AuthAppSonoraStudioPrincipalRoute: AuthAppSonoraStudioPrincipalRoute, +} + +const AuthAppSonoraStudioRouteWithChildren = + AuthAppSonoraStudioRoute._addFileChildren(AuthAppSonoraStudioRouteChildren) + +interface AuthAppSonoraRouteLazyRouteChildren { + AuthAppSonoraArchiveRoute: typeof AuthAppSonoraArchiveRouteWithChildren + AuthAppSonoraMarketRoute: typeof AuthAppSonoraMarketRoute + AuthAppSonoraRecordRoute: typeof AuthAppSonoraRecordRoute + AuthAppSonoraStudioRoute: typeof AuthAppSonoraStudioRouteWithChildren + AuthAppSonoraUploadRoute: typeof AuthAppSonoraUploadRoute + AuthAppSonoraIndexRoute: typeof AuthAppSonoraIndexRoute +} + +const AuthAppSonoraRouteLazyRouteChildren: AuthAppSonoraRouteLazyRouteChildren = + { + AuthAppSonoraArchiveRoute: AuthAppSonoraArchiveRouteWithChildren, + AuthAppSonoraMarketRoute: AuthAppSonoraMarketRoute, + AuthAppSonoraRecordRoute: AuthAppSonoraRecordRoute, + AuthAppSonoraStudioRoute: AuthAppSonoraStudioRouteWithChildren, + AuthAppSonoraUploadRoute: AuthAppSonoraUploadRoute, + AuthAppSonoraIndexRoute: AuthAppSonoraIndexRoute, + } + +const AuthAppSonoraRouteLazyRouteWithChildren = + AuthAppSonoraRouteLazyRoute._addFileChildren( + AuthAppSonoraRouteLazyRouteChildren, + ) + interface AuthRouteLazyRouteChildren { AuthDashboardRouteLazyRoute: typeof AuthDashboardRouteLazyRouteWithChildren AuthSwapRouteLazyRoute: typeof AuthSwapRouteLazyRouteWithChildren AuthAppEmporiumRouteLazyRoute: typeof AuthAppEmporiumRouteLazyRouteWithChildren + AuthAppSonoraRouteLazyRoute: typeof AuthAppSonoraRouteLazyRouteWithChildren AuthAppPinaxLazyRoute: typeof AuthAppPinaxLazyRoute AuthAppPerpetuaIndexLazyRoute: typeof AuthAppPerpetuaIndexLazyRoute AuthAppPerpetuaShelfShelfIdLazyRoute: typeof AuthAppPerpetuaShelfShelfIdLazyRoute @@ -1017,6 +1257,7 @@ const AuthRouteLazyRouteChildren: AuthRouteLazyRouteChildren = { AuthDashboardRouteLazyRoute: AuthDashboardRouteLazyRouteWithChildren, AuthSwapRouteLazyRoute: AuthSwapRouteLazyRouteWithChildren, AuthAppEmporiumRouteLazyRoute: AuthAppEmporiumRouteLazyRouteWithChildren, + AuthAppSonoraRouteLazyRoute: AuthAppSonoraRouteLazyRouteWithChildren, AuthAppPinaxLazyRoute: AuthAppPinaxLazyRoute, AuthAppPerpetuaIndexLazyRoute: AuthAppPerpetuaIndexLazyRoute, AuthAppPerpetuaShelfShelfIdLazyRoute: AuthAppPerpetuaShelfShelfIdLazyRoute, diff --git a/src/alex_frontend/lbry/src/pages/sonora/ArchivePage.tsx b/src/alex_frontend/lbry/src/pages/sonora/ArchivePage.tsx new file mode 100644 index 000000000..c042e1eeb --- /dev/null +++ b/src/alex_frontend/lbry/src/pages/sonora/ArchivePage.tsx @@ -0,0 +1,237 @@ +import React, { useEffect } from "react"; +import { Helmet } from "react-helmet-async"; +import { Button } from "@/lib/components/button"; +import { Link } from "@tanstack/react-router"; +import { + Archive, + User, + FileAudio, + Download, + ArrowDown, + ArrowLeft, + LoaderPinwheel, +} from "lucide-react"; +import { AudioCard } from "@/features/sonora/components/AudioCard"; +import { SellButton } from "@/features/sonora/components/SellButton"; +import { useUserAudioNFTs } from "@/features/sonora/hooks/useUserAudioNFTs"; +import { useSonoraProfileTarget } from "@/features/sonora/hooks/useSonoraProfileTarget"; +import { ArchiveAudio } from "@/features/sonora/types"; +import { shortenPrincipal } from "@/features/perpetua"; + +const SonoraArchivePage: React.FC = () => { + const { targetPrincipal, isOwner } = useSonoraProfileTarget(); + const { + audios, + loading, + loadingMore, + error, + pagination, + refreshAudioNFTs, + } = useUserAudioNFTs(); + + useEffect(() => { + if (targetPrincipal) { + refreshAudioNFTs(targetPrincipal, 1, false); + } + }, [targetPrincipal, refreshAudioNFTs]); + + const handleLoadMore = () => { + if (targetPrincipal && !loadingMore && pagination.hasMore) { + refreshAudioNFTs(targetPrincipal, pagination.page + 1, true); + } + }; + + return ( + <> + + {isOwner ? "My Archive" : `${shortenPrincipal(targetPrincipal ?? "")}'s Archive`} | Sonora | Alexandria + + + {!isOwner && ( +
+ + + Back to my archive + +
+ )} +
+ {/* Left Sidebar */} +
+
+
+
+ +

{isOwner ? "Your Archive" : "Archive"}

+
+

+ {isOwner + ? "Your personal audio NFT collection and uploaded content." + : `Viewing ${shortenPrincipal(targetPrincipal ?? "")}'s audio NFT collection.`} +

+
+
+ + {isOwner ? "Owned by you" : "Owned by this user"} +
+
+ + Ready to trade +
+
+ + Download anytime +
+
+
+
+ +
+
+
+ +

{isOwner ? "Manage Content" : "Collection Info"}

+
+

+ {isOwner + ? "Download, share, or list your audio NFTs for sale." + : "Audio NFTs minted by this user on Alexandria."} +

+ {isOwner && ( +
+
+

+ Available actions: +

+
    +
  • • Download original files
  • +
  • • Share with others
  • +
  • • List on marketplace
  • +
+
+
+ )} +
+

+ + {audios.length} + {" "} + of{" "} + + {pagination.totalCount} + {" "} + items +

+ {isOwner ? ( + + → View your listings + + ) : ( + + → View their listings + + )} +
+
+
+ + {/* Load More */} + {pagination.hasMore && ( + + )} +
+ + {/* Audio Grid */} +
+ {loading ? ( +
+
+ +

+ Loading your audio NFTs... +

+
+
+ ) : error ? ( +
+
+

+ Error loading NFTs +

+

+ {error} +

+ +
+
+ ) : audios.length === 0 ? ( +
+
+ +

+ No Audio NFTs Found +

+

+ {isOwner + ? "You haven't minted any audio NFTs yet." + : "This user hasn't minted any audio NFTs yet."} +

+
+
+ ) : ( + audios.map((item: ArchiveAudio) => ( + + ) : undefined + } + /> + )) + )} +
+
+ + ); +}; + +export default SonoraArchivePage; diff --git a/src/alex_frontend/lbry/src/pages/sonora/BrowsePage.tsx b/src/alex_frontend/lbry/src/pages/sonora/BrowsePage.tsx new file mode 100644 index 000000000..c7b56a845 --- /dev/null +++ b/src/alex_frontend/lbry/src/pages/sonora/BrowsePage.tsx @@ -0,0 +1,210 @@ +import React, { useEffect } from "react"; +import { Helmet } from "react-helmet-async"; +import { Button } from "@/lib/components/button"; +import { + Database, + Globe, + ArrowDown, + FileAudio, + Coins, + AlertCircle, + Loader2, +} from "lucide-react"; +import { AudioCard } from "@/features/sonora/components/AudioCard"; +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 convertToAudio = (arweaveAudio: ArweaveAudio): Audio => { + 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(), + }; +}; + +const BrowsePage: React.FC = () => { + const dispatch = useAppDispatch(); + const { audios, loading, error, hasNext, loadMore, isEmpty } = + useArweaveAudios(); + + // 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 ( + <> + + Browse Audio | Sonora | Alexandria + + +
+ {/* Left Sidebar */} +
+
+
+
+ +

From Arweave

+
+

+ Audio files from the permanent web, ready to + discover and mint. +

+
+
+ + Permanent storage +
+
+ + Always accessible +
+
+ + Ready to mint +
+
+
+
+ +
+
+
+ +

Create NFTs

+
+

+ Transform any audio into a tradeable NFT on + Alexandria. +

+
+
+

+ Quick steps: +

+
    +
  • • Click mint icon on any audio
  • +
  • • Confirm the transaction
  • +
  • • Own the NFT permanently
  • +
+
+
+
+

+ {loading && audios.length === 0 ? ( + "Loading files..." + ) : ( + <> + + {audios.length} + {" "} + files available + + )} +

+
+
+
+ + {/* Load More */} + {hasNext && ( + + )} +
+ + {/* Audio Grid */} +
+ {error && ( +
+ + + {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 ( + } + /> + ); + })} +
+
+ + ); +}; + +export default BrowsePage; diff --git a/src/alex_frontend/lbry/src/pages/sonora/MarketPage.tsx b/src/alex_frontend/lbry/src/pages/sonora/MarketPage.tsx new file mode 100644 index 000000000..556435218 --- /dev/null +++ b/src/alex_frontend/lbry/src/pages/sonora/MarketPage.tsx @@ -0,0 +1,207 @@ +import React, { useEffect } from "react"; +import { Helmet } from "react-helmet-async"; +import { Button } from "@/lib/components/button"; +import { Link } from "@tanstack/react-router"; +import { + Store, + TrendingUp, + Search, + Filter, + ArrowDown, + FileAudio, + LoaderPinwheel, +} from "lucide-react"; +import { AudioCard } from "@/features/sonora/components/AudioCard"; +import { BuyButton } from "@/features/sonora/components/BuyButton"; +import { useMarketAudioNFTs } from "@/features/sonora/hooks/useMarketAudioNFTs"; +import { MarketAudio } from "@/features/sonora/types"; + +const SonoraMarketPage: React.FC = () => { + const { + audios, + loading, + loadingMore, + error, + pagination, + refreshMarketAudioNFTs, + } = useMarketAudioNFTs(); + + useEffect(() => { + refreshMarketAudioNFTs(1, 8, false); + }, [refreshMarketAudioNFTs]); + + // Load More handler + const handleLoadMore = () => { + if (!loadingMore && pagination.page < pagination.totalPages) { + refreshMarketAudioNFTs(pagination.page + 1, 8, true); // Next page, append mode + } + }; + + return ( + <> + + Audio Marketplace | Sonora | Alexandria + + +
+ {/* Left Sidebar */} +
+
+
+
+ +

Marketplace

+
+

+ Discover and purchase audio NFTs from creators + worldwide. +

+
+
+ + Trending content +
+
+ + Search by genre +
+
+ + Filter by price +
+
+
+
+ +
+
+
+ +

Buy & Own

+
+

+ Purchase audio NFTs with ALEX or LBRY tokens and own + them forever. +

+
+
+

+ How it works: +

+
    +
  • • Preview before buying
  • +
  • • Secure blockchain purchase
  • +
  • • Instant ownership transfer
  • +
+
+
+
+

+ + {audios.length} + {" "} + of{" "} + + {pagination.totalCount} + {" "} + items shown +

+ + → Manage your listings + +
+
+
+ + {/* Load More */} + {pagination.page < pagination.totalPages && ( + + )} +
+ + {/* Audio Grid */} +
+ {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: MarketAudio) => ( + + } + /> + )) + )} +
+
+ + ); +}; + +export default SonoraMarketPage; diff --git a/src/alex_frontend/lbry/src/pages/sonora/RecordPage.tsx b/src/alex_frontend/lbry/src/pages/sonora/RecordPage.tsx new file mode 100644 index 000000000..ed4034fd4 --- /dev/null +++ b/src/alex_frontend/lbry/src/pages/sonora/RecordPage.tsx @@ -0,0 +1,354 @@ +import React, { useState, useRef, useEffect } from "react"; +import { Helmet } from "react-helmet-async"; +import { Button } from "@/lib/components/button"; +import { Mic, Square, Upload, Trash2, LoaderPinwheel } from "lucide-react"; +import { Link } from "@tanstack/react-router"; +import { useAppDispatch } from "@/store/hooks/useAppDispatch"; +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 [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); + + useEffect(() => { + return () => { + streamRef.current?.getTracks().forEach((t) => t.stop()); + if (timerRef.current) clearInterval(timerRef.current); + const recorder = mediaRecorderRef.current; + if (recorder?.state === "recording") { + recorder.onstop = null; + recorder.stop(); + } + }; + }, []); + + useEffect(() => { + return () => { + if (recordedUrl.startsWith("blob:")) { + URL.revokeObjectURL(recordedUrl); + } + }; + }, [recordedUrl]); + + const startRecording = async () => { + try { + setRecordingError(""); + const stream = await navigator.mediaDevices.getUserMedia({ + audio: true, + }); + streamRef.current = stream; + + const mediaRecorder = new MediaRecorder(stream); + mediaRecorderRef.current = mediaRecorder; + + const chunks: BlobPart[] = []; + + mediaRecorder.ondataavailable = (event) => { + if (event.data.size > 0) { + chunks.push(event.data); + } + }; + + mediaRecorder.onstop = () => { + const blob = new Blob(chunks, { type: "audio/webm" }); + 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(); + setIsRecording(true); + setRecordingTime(0); + + // Start timer + timerRef.current = setInterval(() => { + setRecordingTime((prev) => prev + 1); + }, 1000); + } catch (err) { + setRecordingError( + "Failed to access microphone. Please check permissions." + ); + } + }; + + const stopRecording = () => { + if (mediaRecorderRef.current && isRecording) { + mediaRecorderRef.current.stop(); + setIsRecording(false); + + if (timerRef.current) { + clearInterval(timerRef.current); + } + + if (streamRef.current) { + streamRef.current.getTracks().forEach((track) => track.stop()); + } + } + }; + + const deleteRecording = () => { + if (recordedUrl) { + URL.revokeObjectURL(recordedUrl); + } + setRecordedBlob(null); + setRecordedUrl(""); + setRecordingTime(0); + dispatch(clearSelected()); + // Reset upload state + resetUpload(); + }; + + const handleUpload = async () => { + if (recordedBlob) { + 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 + } + } + }; + + const formatTime = (seconds: number) => { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`; + }; + + return ( + <> + + Record Audio | Sonora | Alexandria + + +
+
+
+

+ Record audio content directly in your browser +

+
+ + {/* Recording Interface */} +
+
+ {/* Recording Status */} +
+
+ +
+ +
+ {formatTime(recordingTime)} +
+ + {isRecording && ( +

+ Recording in progress... +

+ )} + + {recordedBlob && !isRecording && ( +

+ Recording completed +

+ )} +
+ + {/* Error Message */} + {recordingError && ( +
+

+ {recordingError} +

+
+ )} + + {/* Control Buttons */} +
+ {!isRecording && !recordedBlob && ( + + )} + + {isRecording && ( + + )} +
+
+ + {/* Upload option below recording box */} +
+

+ + + +

+
+
+ + {/* Audio Preview */} + {(recordedBlob || recordedUrl) && selected && !isRecording && ( +
+
+

+ {recordedBlob + ? "Preview" + : "Uploaded to Arweave"} +

+ +
+ +
+ )} + + {/* Upload Error and Success Messages */} + {uploadError && ( +
+

+ {uploadError} +

+
+ )} + + {success && ( +
+

{success}

+
+ )} + + {/* Upload Button */} + {recordedBlob && ( +
+ +
+ )} +
+
+ + ); +}; + +export default SonoraRecordPage; diff --git a/src/alex_frontend/lbry/src/pages/sonora/StudioPage.tsx b/src/alex_frontend/lbry/src/pages/sonora/StudioPage.tsx new file mode 100644 index 000000000..fcedc5db3 --- /dev/null +++ b/src/alex_frontend/lbry/src/pages/sonora/StudioPage.tsx @@ -0,0 +1,249 @@ +import React, { useEffect } from "react"; +import { Helmet } from "react-helmet-async"; +import { Button } from "@/lib/components/button"; +import { Link } from "@tanstack/react-router"; +import { + Palette, + Settings, + TrendingUp, + BarChart3, + ArrowDown, + ArrowLeft, + FileAudio, + LoaderPinwheel, +} from "lucide-react"; +import { AudioCard } from "@/features/sonora/components/AudioCard"; +import { EditButton } from "@/features/sonora/components/EditButton"; +import { UnlistButton } from "@/features/sonora/components/UnlistButton"; +import { useStudioAudioNFTs } from "@/features/sonora/hooks/useStudioAudioNFTs"; +import { useSonoraProfileTarget } from "@/features/sonora/hooks/useSonoraProfileTarget"; +import { StudioAudio } from "@/features/sonora/types"; +import { shortenPrincipal } from "@/features/perpetua"; + +const SonoraStudioPage: React.FC = () => { + const { targetPrincipal, isOwner } = useSonoraProfileTarget(); + const { + audios, + loading, + loadingMore, + error, + pagination, + refreshStudioAudioNFTs, + } = useStudioAudioNFTs(); + + useEffect(() => { + if (targetPrincipal) { + refreshStudioAudioNFTs(targetPrincipal, 1, 8, false); + } + }, [targetPrincipal, refreshStudioAudioNFTs]); + + const handleLoadMore = () => { + if ( + targetPrincipal && + !loadingMore && + pagination.page < pagination.totalPages + ) { + refreshStudioAudioNFTs( + targetPrincipal, + pagination.page + 1, + 8, + true + ); + } + }; + + return ( + <> + + {isOwner ? "My Studio" : `${shortenPrincipal(targetPrincipal ?? "")}'s Studio`} | Sonora | Alexandria + + + {!isOwner && ( +
+ + + Back to my studio + +
+ )} +
+ {/* Left Sidebar */} +
+
+
+
+ +

{isOwner ? "Creator Studio" : "Studio"}

+
+

+ {isOwner + ? "Manage your listed audio NFTs and marketplace presence." + : `Viewing ${shortenPrincipal(targetPrincipal ?? "")}'s listed audio NFTs.`} +

+ {isOwner && ( +
+
+ + Track sales +
+
+ + Edit listings +
+
+ + View analytics +
+
+ )} +
+
+ + {isOwner && ( +
+
+
+ +

Manage Listings

+
+

+ Edit metadata, pricing, and availability of your + audio NFTs. +

+
+
+

+ Available actions: +

+
    +
  • • Edit titles and descriptions
  • +
  • • Update pricing
  • +
  • • Remove from marketplace
  • +
+
+
+
+

+ + {audios.length} + {" "} + of{" "} + + {pagination.totalCount} + {" "} + listings shown +

+
+
+
+ )} + + {/* Load More */} + {pagination.page < pagination.totalPages && ( + + )} +
+ + {/* Audio Grid */} +
+ {loading ? ( +
+
+ +

+ Loading your listed audio NFTs... +

+
+
+ ) : error ? ( +
+
+

+ Error loading listed audio NFTs +

+

+ {error} +

+ +
+
+ ) : audios.length === 0 ? ( +
+
+ +

+ No Active Listings +

+

+ {isOwner + ? "You don't have any audio NFTs listed for sale yet." + : "This user doesn't have any audio NFTs listed for sale."} +

+
+
+ ) : ( + audios.map((item: StudioAudio) => ( + + + + + ) : undefined + } + /> + )) + )} +
+
+ + ); +}; + +export default SonoraStudioPage; diff --git a/src/alex_frontend/lbry/src/pages/sonora/UploadPage.tsx b/src/alex_frontend/lbry/src/pages/sonora/UploadPage.tsx new file mode 100644 index 000000000..afbb83b93 --- /dev/null +++ b/src/alex_frontend/lbry/src/pages/sonora/UploadPage.tsx @@ -0,0 +1,267 @@ +import React, { useState, useRef, useEffect } from "react"; +import { Helmet } from "react-helmet-async"; +import { toast } from "sonner"; +import { Button } from "@/lib/components/button"; +import { Upload, Mic, X, LoaderPinwheel } from "lucide-react"; +import { Link } from "@tanstack/react-router"; +import { useAppDispatch } from "@/store/hooks/useAppDispatch"; +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); + const [audioUrl, setAudioUrl] = useState(""); + 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(); + + useEffect(() => { + return () => { + if (audioUrl.startsWith("blob:")) { + URL.revokeObjectURL(audioUrl); + } + }; + }, [audioUrl]); + + const handleFileSelect = (file: File) => { + // Validate audio file + if (!file.type.startsWith("audio/")) { + toast.error("Please select an audio file"); + return; + } + + setSelectedFile(file); + // Create object URL for preview + const url = URL.createObjectURL(file); + setAudioUrl(url); + + // Create Audio object for Redux + const audioData: Audio = { + id: url, // Use object URL as ID for local files + type: file.type, + size: `${(file.size / (1024 * 1024)).toFixed(2)} MB`, + timestamp: new Date().toISOString(), + }; + + // Set audio in global state for player + dispatch(setSelected(audioData)); + }; + + const handleFileInput = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) handleFileSelect(file); + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(true); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + const file = e.dataTransfer.files[0]; + if (file) handleFileSelect(file); + }; + + const handleRemoveFile = () => { + if (audioUrl) { + URL.revokeObjectURL(audioUrl); + } + setSelectedFile(null); + setAudioUrl(""); + if (fileInputRef.current) fileInputRef.current.value = ""; + // Clear audio from global state + dispatch(clearSelected()); + // Reset upload state + resetUpload(); + }; + + const handleUpload = async () => { + if (selectedFile) { + 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 + } + } + }; + + return ( + <> + + Upload Audio | Sonora | Alexandria + + +
+
+
+

+ Share your audio content with the world +

+
+ + {/* File Upload Area */} +
+
+ + +
+ +
+

+ {selectedFile + ? selectedFile.name + : "Drop your audio file here"} +

+

+ or click to browse • Supports MP3, WAV, OGG +

+
+
+
+ + {/* Record option below upload box */} +
+

+ + + +

+
+
+ + {/* Audio Preview */} + {selectedFile && selected && ( +
+
+

+ {audioUrl.startsWith("blob:") + ? "Preview" + : "Uploaded to Arweave"} +

+ +
+ +
+ )} + + {/* Error and Success Messages */} + {error && ( +
+

{error}

+
+ )} + + {success && ( +
+

{success}

+
+ )} + + {/* Upload Button */} + {selectedFile && audioUrl.startsWith("blob:") && ( +
+ +
+ )} +
+
+ + ); +}; + +export default SonoraUploadPage; diff --git a/src/alex_frontend/lbry/src/routes/_auth/app/sonora/archive.$principal.lazy.tsx b/src/alex_frontend/lbry/src/routes/_auth/app/sonora/archive.$principal.lazy.tsx new file mode 100644 index 000000000..525ec4c98 --- /dev/null +++ b/src/alex_frontend/lbry/src/routes/_auth/app/sonora/archive.$principal.lazy.tsx @@ -0,0 +1,6 @@ +import { createLazyFileRoute } from '@tanstack/react-router' +import ArchivePage from './../../../../pages/sonora/ArchivePage' + +export const Route = createLazyFileRoute('/_auth/app/sonora/archive/$principal')({ + component: ArchivePage, +}) diff --git a/src/alex_frontend/lbry/src/routes/_auth/app/sonora/archive.$principal.tsx b/src/alex_frontend/lbry/src/routes/_auth/app/sonora/archive.$principal.tsx new file mode 100644 index 000000000..f247017b3 --- /dev/null +++ b/src/alex_frontend/lbry/src/routes/_auth/app/sonora/archive.$principal.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/_auth/app/sonora/archive/$principal')({ + loader: () => void 0, +}) diff --git a/src/alex_frontend/lbry/src/routes/_auth/app/sonora/archive.lazy.tsx b/src/alex_frontend/lbry/src/routes/_auth/app/sonora/archive.lazy.tsx new file mode 100644 index 000000000..74e3a2d8a --- /dev/null +++ b/src/alex_frontend/lbry/src/routes/_auth/app/sonora/archive.lazy.tsx @@ -0,0 +1,6 @@ +import { createLazyFileRoute } from '@tanstack/react-router' +import ArchivePage from './../../../../pages/sonora/ArchivePage' + +export const Route = createLazyFileRoute('/_auth/app/sonora/archive')({ + component: ArchivePage, +}) diff --git a/src/alex_frontend/lbry/src/routes/_auth/app/sonora/archive.tsx b/src/alex_frontend/lbry/src/routes/_auth/app/sonora/archive.tsx new file mode 100644 index 000000000..c5e6f018c --- /dev/null +++ b/src/alex_frontend/lbry/src/routes/_auth/app/sonora/archive.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/_auth/app/sonora/archive')({ + loader: () => void 0, +}) diff --git a/src/alex_frontend/lbry/src/routes/_auth/app/sonora/index.lazy.tsx b/src/alex_frontend/lbry/src/routes/_auth/app/sonora/index.lazy.tsx new file mode 100644 index 000000000..dd6e029fb --- /dev/null +++ b/src/alex_frontend/lbry/src/routes/_auth/app/sonora/index.lazy.tsx @@ -0,0 +1,6 @@ +import { createLazyFileRoute } from '@tanstack/react-router' +import BrowsePage from './../../../../pages/sonora/BrowsePage' + +export const Route = createLazyFileRoute('/_auth/app/sonora/')({ + component: BrowsePage, +}) diff --git a/src/alex_frontend/lbry/src/routes/_auth/app/sonora/index.tsx b/src/alex_frontend/lbry/src/routes/_auth/app/sonora/index.tsx new file mode 100644 index 000000000..b26e9a1e7 --- /dev/null +++ b/src/alex_frontend/lbry/src/routes/_auth/app/sonora/index.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/_auth/app/sonora/')({ + loader: () => void 0, +}) diff --git a/src/alex_frontend/lbry/src/routes/_auth/app/sonora/market.lazy.tsx b/src/alex_frontend/lbry/src/routes/_auth/app/sonora/market.lazy.tsx new file mode 100644 index 000000000..02309f9d3 --- /dev/null +++ b/src/alex_frontend/lbry/src/routes/_auth/app/sonora/market.lazy.tsx @@ -0,0 +1,6 @@ +import { createLazyFileRoute } from '@tanstack/react-router' +import MarketPage from './../../../../pages/sonora/MarketPage' + +export const Route = createLazyFileRoute('/_auth/app/sonora/market')({ + component: MarketPage, +}) diff --git a/src/alex_frontend/lbry/src/routes/_auth/app/sonora/market.tsx b/src/alex_frontend/lbry/src/routes/_auth/app/sonora/market.tsx new file mode 100644 index 000000000..8f32560c4 --- /dev/null +++ b/src/alex_frontend/lbry/src/routes/_auth/app/sonora/market.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/_auth/app/sonora/market')({ + loader: () => void 0, +}) diff --git a/src/alex_frontend/lbry/src/routes/_auth/app/sonora/record.lazy.tsx b/src/alex_frontend/lbry/src/routes/_auth/app/sonora/record.lazy.tsx new file mode 100644 index 000000000..0ff9d0ce9 --- /dev/null +++ b/src/alex_frontend/lbry/src/routes/_auth/app/sonora/record.lazy.tsx @@ -0,0 +1,6 @@ +import { createLazyFileRoute } from '@tanstack/react-router' +import RecordPage from './../../../../pages/sonora/RecordPage' + +export const Route = createLazyFileRoute('/_auth/app/sonora/record')({ + component: RecordPage, +}) diff --git a/src/alex_frontend/lbry/src/routes/_auth/app/sonora/record.tsx b/src/alex_frontend/lbry/src/routes/_auth/app/sonora/record.tsx new file mode 100644 index 000000000..88c4a3d56 --- /dev/null +++ b/src/alex_frontend/lbry/src/routes/_auth/app/sonora/record.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/_auth/app/sonora/record')({ + loader: () => void 0, +}) diff --git a/src/alex_frontend/lbry/src/routes/_auth/app/sonora/route.lazy.tsx b/src/alex_frontend/lbry/src/routes/_auth/app/sonora/route.lazy.tsx new file mode 100644 index 000000000..cea51cbe4 --- /dev/null +++ b/src/alex_frontend/lbry/src/routes/_auth/app/sonora/route.lazy.tsx @@ -0,0 +1,6 @@ +import SonoraLayout from '@/layouts/SonoraLayout' +import { createLazyFileRoute } from '@tanstack/react-router' + +export const Route = createLazyFileRoute('/_auth/app/sonora')({ + component: SonoraLayout, +}) diff --git a/src/alex_frontend/lbry/src/routes/_auth/app/sonora/studio.$principal.lazy.tsx b/src/alex_frontend/lbry/src/routes/_auth/app/sonora/studio.$principal.lazy.tsx new file mode 100644 index 000000000..9b697a1a0 --- /dev/null +++ b/src/alex_frontend/lbry/src/routes/_auth/app/sonora/studio.$principal.lazy.tsx @@ -0,0 +1,6 @@ +import { createLazyFileRoute } from '@tanstack/react-router' +import StudioPage from './../../../../pages/sonora/StudioPage' + +export const Route = createLazyFileRoute('/_auth/app/sonora/studio/$principal')({ + component: StudioPage, +}) diff --git a/src/alex_frontend/lbry/src/routes/_auth/app/sonora/studio.$principal.tsx b/src/alex_frontend/lbry/src/routes/_auth/app/sonora/studio.$principal.tsx new file mode 100644 index 000000000..3d509df98 --- /dev/null +++ b/src/alex_frontend/lbry/src/routes/_auth/app/sonora/studio.$principal.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/_auth/app/sonora/studio/$principal')({ + loader: () => void 0, +}) diff --git a/src/alex_frontend/lbry/src/routes/_auth/app/sonora/studio.lazy.tsx b/src/alex_frontend/lbry/src/routes/_auth/app/sonora/studio.lazy.tsx new file mode 100644 index 000000000..a3d7583a0 --- /dev/null +++ b/src/alex_frontend/lbry/src/routes/_auth/app/sonora/studio.lazy.tsx @@ -0,0 +1,6 @@ +import { createLazyFileRoute } from '@tanstack/react-router' +import StudioPage from './../../../../pages/sonora/StudioPage' + +export const Route = createLazyFileRoute('/_auth/app/sonora/studio')({ + component: StudioPage, +}) diff --git a/src/alex_frontend/lbry/src/routes/_auth/app/sonora/studio.tsx b/src/alex_frontend/lbry/src/routes/_auth/app/sonora/studio.tsx new file mode 100644 index 000000000..511bc356b --- /dev/null +++ b/src/alex_frontend/lbry/src/routes/_auth/app/sonora/studio.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/_auth/app/sonora/studio')({ + loader: () => void 0, +}) diff --git a/src/alex_frontend/lbry/src/routes/_auth/app/sonora/upload.lazy.tsx b/src/alex_frontend/lbry/src/routes/_auth/app/sonora/upload.lazy.tsx new file mode 100644 index 000000000..83b8aa2a4 --- /dev/null +++ b/src/alex_frontend/lbry/src/routes/_auth/app/sonora/upload.lazy.tsx @@ -0,0 +1,6 @@ +import { createLazyFileRoute } from '@tanstack/react-router' +import UploadPage from './../../../../pages/sonora/UploadPage' + +export const Route = createLazyFileRoute('/_auth/app/sonora/upload')({ + component: UploadPage, +}) diff --git a/src/alex_frontend/lbry/src/routes/_auth/app/sonora/upload.tsx b/src/alex_frontend/lbry/src/routes/_auth/app/sonora/upload.tsx new file mode 100644 index 000000000..15a7ac587 --- /dev/null +++ b/src/alex_frontend/lbry/src/routes/_auth/app/sonora/upload.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/_auth/app/sonora/upload')({ + loader: () => void 0, +})