Treasury
Shared vault for the community. Create & vote on proposals to access funds.
diff --git a/app/globals.css b/app/globals.css
index 596dc3a38..4939beb9e 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -10,6 +10,7 @@
@import './styles/components/input.css';
@import './styles/components/tooltips.css';
@import './styles/components/tiptap.css';
+@import './styles/components/range.css';
@import './styles/presets/minimal.css';
@import './styles/presets/shader.css';
diff --git a/app/styles/components/range.css b/app/styles/components/range.css
new file mode 100644
index 000000000..cc1246b80
--- /dev/null
+++ b/app/styles/components/range.css
@@ -0,0 +1,29 @@
+@layer utilities {
+ .range {
+ -webkit-appearance: none;
+ --range-thumb: var(--color-primary);
+ width: 100%;
+ background: var(--color-divider);
+ height: 4px;
+ border-radius: 9999;
+ cursor: pointer;
+ outline: none;
+
+ &::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ background: var(--range-thumb);
+ border-radius: 9999;
+ width: 16px;
+ height: 16px;
+ transition: 0.2s ease-in-out;
+ }
+
+ &::-moz-range-thumb {
+ background: var(--range-thumb);
+ border-radius: 9999;
+ width: 16px;
+ height: 16px;
+ transition: 0.2s ease-in-out;
+ }
+ }
+}
diff --git a/app/styles/themes/light.css b/app/styles/themes/light.css
index 764ceaf92..fe02c5156 100644
--- a/app/styles/themes/light.css
+++ b/app/styles/themes/light.css
@@ -3,6 +3,7 @@
color-scheme: light;
--color-background: var(--color-woodsmoke-50);
+ --color-page-background-overlay: rgba(247, 247, 248, 0.8);
--color-primary: #000000;
--color-primary-invert: #ffffff;
diff --git a/lib/components/core/progress/radial.tsx b/lib/components/core/progress/radial.tsx
new file mode 100644
index 000000000..62d3bf68c
--- /dev/null
+++ b/lib/components/core/progress/radial.tsx
@@ -0,0 +1,31 @@
+'use client';
+import { twMerge } from 'tailwind-merge';
+
+export function RadialProgress({ size, color, value = 0 }: { size: string; color: string; value?: number }) {
+ return (
+
+
+
+ );
+}
diff --git a/lib/components/features/community/HubMusicPlayer.tsx b/lib/components/features/community/HubMusicPlayer.tsx
new file mode 100644
index 000000000..f2c5db290
--- /dev/null
+++ b/lib/components/features/community/HubMusicPlayer.tsx
@@ -0,0 +1,568 @@
+'use client';
+import React from 'react';
+
+import { Button, Card, modal, ModalContent, Skeleton, toast } from '$lib/components/core';
+import { ASSET_PREFIX } from '$lib/utils/constants';
+import { twMerge } from 'tailwind-merge';
+import { formatNumberWithCommas } from '$lib/utils/string';
+import { match } from 'ts-pattern';
+import { useQuery } from '$lib/graphql/request';
+import { GetSpaceNfTsDocument, SpaceNft, SpaceNftContract, SpaceNftKind } from '$lib/graphql/generated/backend/graphql';
+import { RadialProgress } from '$lib/components/core/progress/radial';
+import { atom, useAtomValue, useSetAtom } from 'jotai';
+import { ConnectWallet } from '../modals/ConnectWallet';
+import { chainsMapAtom } from '$lib/jotai';
+import { Eip1193Provider, ethers } from 'ethers';
+import { ConfirmTransaction } from '../modals/ConfirmTransaction';
+import { SignTransactionModal } from '../modals/SignTransaction';
+import { useAppKitAccount, useAppKitProvider } from '@reown/appkit/react';
+import { formatError, MusicNftContract, writeContract } from '$lib/utils/crypto';
+import * as Sentry from '@sentry/nextjs';
+import { appKit } from '$lib/utils/appkit';
+import { useMusicNft } from '$lib/hooks/useMusicNft';
+import { mainnet } from 'viem/chains';
+import { useGetEns } from '$lib/hooks/useGetEnsName';
+import { delay } from 'lodash';
+import { useQueryClient } from '@tanstack/react-query';
+
+const musicState = atom({ playing: false, _id: '' });
+
+export function HubMusicPlayer({ spaceId }: { spaceId: string }) {
+ const [track, setTrack] = React.useState
();
+ const [mounted, setMounted] = React.useState(false);
+
+ const { data, loading, fetchMore } = useQuery(GetSpaceNfTsDocument, {
+ variables: { space: spaceId, skip: 0, limit: 100, kind: SpaceNftKind.MusicTrack },
+ });
+ const list = (data?.listSpaceNFTs?.items || []) as SpaceNft[];
+ const vinylRef = React.useRef(null);
+
+ React.useEffect(() => {
+ if (list.length && !track) {
+ setTrack(list[0]);
+ setMounted(true);
+ }
+ }, [list.length, track]);
+
+ return (
+
+
{
+ const idx = list.findIndex((i) => i._id === track?._id);
+ if (idx + 1 <= list.length) {
+ await setTrack(list[idx + 1]);
+ vinylRef.current?.onChangeTrack();
+ }
+ }}
+ onPrev={async () => {
+ const idx = list.findIndex((i) => i._id === track?._id);
+ if (idx - 1 >= 0) {
+ await setTrack(list[idx - 1]);
+ vinylRef.current?.onChangeTrack();
+ }
+ }}
+ />
+
+
+ {
+ await setTrack(t);
+ if (played) vinylRef.current?.onChangeTrack();
+ }}
+ onLoadMore={() => {
+ if (data?.listSpaceNFTs && list.length < data.listSpaceNFTs.total) {
+ fetchMore({ variables: { skip: data?.listSpaceNFTs.items.length } });
+ }
+ }}
+ />
+
+
+ );
+}
+
+interface VinylProps {
+ track?: SpaceNft;
+ onNext?: React.MouseEventHandler;
+ onPrev?: React.MouseEventHandler;
+}
+
+// eslint-disable-next-line react/display-name
+const Vinyl = React.forwardRef(({ track, onNext, onPrev }: VinylProps, ref) => {
+ const contract = track?.contracts?.[0];
+ const { handleMint, status } = useMintMusicNft({
+ network_id: contract?.network_id,
+ contract,
+ });
+
+ const { data } = useMusicNft({
+ network_id: contract?.network_id,
+ contractAddress: contract?.deployed_contract_address,
+ });
+
+ const ensData = useGetEns(data?.owner);
+
+ const audioRef = React.useRef(null);
+ const setMusicState = useSetAtom(musicState);
+
+ const [isPlaying, setIsPlaying] = React.useState(false);
+ const [currentTime, setCurrentTime] = React.useState(0);
+ const [duration, setDuration] = React.useState(0);
+
+ const togglePlayPause = () => {
+ if (audioRef.current) {
+ if (isPlaying) {
+ audioRef.current.pause();
+ } else {
+ audioRef.current.play();
+ }
+ setIsPlaying(!isPlaying);
+ setMusicState((prev) => ({ ...prev, playing: !isPlaying }));
+ }
+ };
+
+ // Update state during playback (using useCallback for stability)
+ const handleTimeUpdate = React.useCallback(() => {
+ if (audioRef.current) {
+ setCurrentTime(audioRef.current.currentTime);
+ }
+ }, []);
+
+ // Set duration once metadata loads
+ const handleLoadedMetadata = React.useCallback(() => {
+ if (audioRef.current) {
+ setDuration(audioRef.current.duration);
+ }
+ }, []);
+
+ // Sync React state with the audio element's events
+ React.useEffect(() => {
+ const audio = audioRef.current;
+ if (audio) {
+ // Add event listeners
+ audio.addEventListener('timeupdate', handleTimeUpdate);
+ audio.addEventListener('loadedmetadata', handleLoadedMetadata);
+ audio.addEventListener('ended', () => {
+ setIsPlaying(false);
+ setMusicState({ playing: false, _id: track?._id });
+ });
+
+ // Cleanup listeners when component unmounts
+ return () => {
+ audio.removeEventListener('timeupdate', handleTimeUpdate);
+ audio.removeEventListener('loadedmetadata', handleLoadedMetadata);
+ };
+ }
+ }, [handleTimeUpdate, handleLoadedMetadata]);
+
+ React.useImperativeHandle(
+ ref,
+ () => ({
+ onChangeTrack: () => {
+ if (track && audioRef.current) {
+ audioRef.current?.pause();
+ audioRef.current?.load();
+ audioRef.current?.play();
+ setIsPlaying(true);
+ setMusicState((prev) => ({ ...prev, playing: true }));
+ }
+ },
+ }),
+ [track],
+ );
+
+ // Handle seeking via the progress bar
+ const handleSeek = (e: React.ChangeEvent) => {
+ if (audioRef.current) {
+ const newTime = Number(e.currentTarget.value);
+ audioRef.current.currentTime = newTime;
+ setCurrentTime(newTime);
+ }
+ };
+
+ // Helper function to format time (MM:SS)
+ const formatTime = (time: number) => {
+ if (isNaN(time)) return '0:00';
+ const minutes = Math.floor(time / 60);
+ const seconds = Math.floor(time % 60);
+ return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`;
+ };
+
+ const progress = (currentTime * 100) / duration;
+
+ return (
+ <>
+
+
+
+

+
+
+ {track?.cover_image_url && (
+

+ )}
+
+
+
+ {track?.cover_image_url && (
+
+ )}
+
+
+
+
+
+
+
+
+
{track?.name || '--'}
+
+
+
{ensData.username || '--'}
+
+
+
+
+
+ {formatNumberWithCommas(data?.totalMinted)} / {formatNumberWithCommas(track?.token_limit)} left
+
+
+
+
+
+
+
+
+
+
{formatTime(currentTime)}
+
{formatTime(duration)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {contract && (
+
+ )}
+
+
+
+
+
+
+ >
+ );
+});
+
+function TrackList({
+ loading,
+ data,
+ selected,
+ onSelect,
+ onLoadMore,
+}: {
+ loading?: boolean;
+ data: SpaceNft[];
+ selected?: SpaceNft;
+ onLoadMore?: () => void;
+ onSelect?: (track: SpaceNft, shouldPlay: boolean) => void;
+}) {
+ const { ref } = useScrollable(onLoadMore);
+
+ return (
+
+ Up Next
+
+
+ {match(loading)
+ .with(true, () =>
+ Array.from({ length: 10 }).map((_, idx) => (
+
+
+
+
+
+
+
+
+
+
+
+ )),
+ )
+ .otherwise(() =>
+ data.map((item: SpaceNft) => (
+
+ )),
+ )}
+
+
+
+ );
+}
+
+function TrackListItem({
+ track,
+ onSelect,
+ active,
+}: {
+ track: SpaceNft;
+ onSelect?: (track: SpaceNft, shouldPlay: boolean) => void;
+ active?: boolean;
+}) {
+ const contract = track.contracts?.[0];
+ const { data } = useMusicNft({
+ network_id: contract?.network_id,
+ contractAddress: contract?.deployed_contract_address,
+ });
+ const ensData = useGetEns(data?.owner);
+
+ return (
+ onSelect?.(track, false)}>
+
+
+

+
+
+
+
{track.name}
+
{ensData.username || '--'}
+
+
+ {formatNumberWithCommas(data?.totalMinted || 0)} / {formatNumberWithCommas(track.token_limit)} left
+
+
+
+
+
+
+ {match(active)
+ .with(true, () => (
+
+ ))
+ .otherwise(() => (
+
+
+
+ );
+}
+
+function useScrollable(callback?: () => void) {
+ const container = React.useRef(null);
+
+ React.useEffect(() => {
+ const divElement = container.current;
+ if (divElement) {
+ divElement.addEventListener('scroll', handleScroll);
+ handleScroll();
+ }
+
+ return () => {
+ if (divElement) {
+ divElement.removeEventListener('scroll', handleScroll);
+ }
+ };
+ }, []);
+
+ const handleScroll = () => {
+ if (container.current) {
+ const { scrollTop, clientHeight, scrollHeight } = container.current;
+ const atBottom = scrollTop + clientHeight >= scrollHeight - 10;
+ if (atBottom) callback?.();
+ }
+ };
+
+ return { ref: container };
+}
+
+function useMintMusicNft({ contract, network_id }: { contract?: SpaceNftContract; network_id?: string }) {
+ const queryClient = useQueryClient();
+ const { walletProvider } = useAppKitProvider('eip155');
+ const { address } = useAppKitAccount();
+
+ const chainsMap = useAtomValue(chainsMapAtom);
+ const { isConnected } = useAppKitAccount();
+ const [status, setStatus] = React.useState<'signing' | 'confirming' | 'success' | 'none'>('none');
+
+ const handleMint = () => {
+ if (!network_id) {
+ toast.error('Missing network_id');
+ return;
+ }
+
+ if (isConnected) {
+ mintMusicNft();
+ return;
+ }
+
+ modal.open(ConnectWallet, {
+ props: {
+ onConnect: () => {
+ handleMint();
+ },
+ chain: chainsMap[network_id],
+ },
+ });
+ };
+
+ const mintMusicNft = async () => {
+ const contractAddress = contract?.deployed_contract_address;
+ if (!contractAddress) {
+ toast.error('Missing contract address');
+ return;
+ }
+
+ try {
+ setStatus('signing');
+ modal.open(MintModal, { props: { onSign: handleMint, status: 'signing' } });
+
+ const transaction = await writeContract(
+ MusicNftContract,
+ contractAddress,
+ walletProvider as Eip1193Provider,
+ 'mint',
+ [address],
+ { value: contract.mint_price },
+ );
+ modal.close();
+ setStatus('confirming');
+ modal.open(MintModal, { props: { onSign: handleMint, status: 'confirming' } });
+
+ await transaction.wait();
+ setStatus('success');
+ delay(() => {
+ modal.close();
+ toast.success('Mint success');
+ queryClient.invalidateQueries({ queryKey: ['music_nft', contract.deployed_contract_address] });
+ }, 500);
+ } catch (error: any) {
+ Sentry.captureException(error, {
+ extra: {
+ walletInfo: appKit.getWalletInfo(),
+ },
+ });
+ modal.close();
+ toast.error(formatError(error));
+ setStatus('none');
+ }
+ };
+
+ return { status, handleMint };
+}
+
+function MintModal({ status, onSign }: { status: 'confirming' | 'signing' | 'success' | 'none'; onSign: () => void }) {
+ return (
+
+ {match(status)
+ .with('confirming', () => (
+
+ ))
+ .with('signing', () => (
+ modal.close()}
+ description="Please sign the transaction to pay gas fees & claim your music nft."
+ onSign={onSign}
+ loading={true}
+ />
+ ))
+ .otherwise(() => null)}
+
+ );
+}
diff --git a/lib/components/layouts/community/container.tsx b/lib/components/layouts/community/container.tsx
index 90ac2e793..13d18f71b 100644
--- a/lib/components/layouts/community/container.tsx
+++ b/lib/components/layouts/community/container.tsx
@@ -26,11 +26,7 @@ export function CommunityContainer({ space, children }: React.PropsWithChildren
-
+
{children}
diff --git a/lib/components/layouts/community/hooks.ts b/lib/components/layouts/community/hooks.ts
index 4b655d176..5140a676c 100644
--- a/lib/components/layouts/community/hooks.ts
+++ b/lib/components/layouts/community/hooks.ts
@@ -20,6 +20,14 @@ export function useSpaceMenu({ space, isMobile }: { space: Space; isMobile?: boo
{ icon: 'icon-bar-chart', path: 'leaderboards', label: 'Leaderboard' },
];
+ if (space.nft_enabled) {
+ menu.push({
+ icon: 'icon-music-fill',
+ path: 'music',
+ label: 'Music',
+ });
+ }
+
if (isMobile) {
menu = [
{ icon: 'icon-home', path: '', label: 'Home' },
@@ -44,6 +52,14 @@ export function useSpaceMenu({ space, isMobile }: { space: Space; isMobile?: boo
});
}
+ if (space.nft_enabled) {
+ menu.push({
+ icon: 'icon-music-fill',
+ path: 'music',
+ label: 'Music',
+ });
+ }
+
if (space.sub_spaces) {
menu.push({
icon: 'icon-sub-hubs',
diff --git a/lib/icons/add-check-sharp.svg b/lib/icons/add-check-sharp.svg
new file mode 100644
index 000000000..c6473ffe1
--- /dev/null
+++ b/lib/icons/add-check-sharp.svg
@@ -0,0 +1,3 @@
+
diff --git a/lib/icons/bar-chart-rounded.svg b/lib/icons/bar-chart-rounded.svg
new file mode 100644
index 000000000..9d62b2f21
--- /dev/null
+++ b/lib/icons/bar-chart-rounded.svg
@@ -0,0 +1,3 @@
+
diff --git a/lib/icons/music-fill.svg b/lib/icons/music-fill.svg
new file mode 100644
index 000000000..f18806252
--- /dev/null
+++ b/lib/icons/music-fill.svg
@@ -0,0 +1,3 @@
+
diff --git a/lib/utils/string.ts b/lib/utils/string.ts
index b48fe22ef..974c2e74b 100644
--- a/lib/utils/string.ts
+++ b/lib/utils/string.ts
@@ -75,3 +75,13 @@ export function formatNumber(str: string) {
const num = parseFloat(str);
return num.toString();
}
+
+/**
+ * @description this format str to number
+ * Ex:
+ * numberWithCommas(1000) -> "1,000"
+ **/
+export function formatNumberWithCommas(num?: number) {
+ if (!num) return '0';
+ return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
+}
diff --git a/public/assets/audio/example.mp3 b/public/assets/audio/example.mp3
new file mode 100644
index 000000000..a849d7f23
Binary files /dev/null and b/public/assets/audio/example.mp3 differ
diff --git a/public/assets/audio/example_1.mp3 b/public/assets/audio/example_1.mp3
new file mode 100644
index 000000000..59594c716
Binary files /dev/null and b/public/assets/audio/example_1.mp3 differ
diff --git a/public/assets/audio/example_2.mp3 b/public/assets/audio/example_2.mp3
new file mode 100644
index 000000000..6643fdd45
Binary files /dev/null and b/public/assets/audio/example_2.mp3 differ
diff --git a/public/assets/audio/example_3.mp3 b/public/assets/audio/example_3.mp3
new file mode 100644
index 000000000..a28b3550e
Binary files /dev/null and b/public/assets/audio/example_3.mp3 differ
diff --git a/public/assets/audio/example_4.mp3 b/public/assets/audio/example_4.mp3
new file mode 100644
index 000000000..a28b3550e
Binary files /dev/null and b/public/assets/audio/example_4.mp3 differ
diff --git a/public/assets/images/vinyl.png b/public/assets/images/vinyl.png
new file mode 100644
index 000000000..257c6ca61
Binary files /dev/null and b/public/assets/images/vinyl.png differ