Compare commits
10
Commits
36d1711fb1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f121e33946 | ||
|
|
fa1fb7553f | ||
|
|
2c8f849e9f | ||
|
|
d05e49b9a7 | ||
|
|
f4e877af7a | ||
|
|
de1aaf6eed | ||
|
|
ef251a4be2 | ||
|
|
9ea4a0c92e | ||
|
|
0c02eacfec | ||
|
|
cc284f7f78 |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
import { useRef, useMemo } from 'react'
|
||||
import { Canvas, useFrame } from '@react-three/fiber'
|
||||
import * as THREE from 'three'
|
||||
|
||||
const BAR_COUNT = 28
|
||||
|
||||
function Waveform({ isPlaying }) {
|
||||
const barsRef = useRef([])
|
||||
const phases = useMemo(() => Array.from({ length: BAR_COUNT }, () => Math.random() * Math.PI * 2), [])
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
const t = clock.getElapsedTime()
|
||||
barsRef.current.forEach((mesh, i) => {
|
||||
if (!mesh) return
|
||||
const target = isPlaying
|
||||
? Math.abs(Math.sin(t * 3.5 + phases[i])) * 0.7 + 0.04
|
||||
: 0.04 + Math.abs(Math.sin(t * 0.4 + phases[i])) * 0.08
|
||||
mesh.scale.y = THREE.MathUtils.lerp(mesh.scale.y, target, 0.18)
|
||||
mesh.position.y = mesh.scale.y * 0.5
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<group>
|
||||
{Array.from({ length: BAR_COUNT }, (_, i) => (
|
||||
<mesh
|
||||
key={i}
|
||||
ref={el => (barsRef.current[i] = el)}
|
||||
position={[(i - BAR_COUNT / 2) * 0.075, 0.04, 0]}
|
||||
>
|
||||
<boxGeometry args={[0.05, 1, 0.05]} />
|
||||
<meshBasicMaterial color={i % 2 === 0 ? '#c9a84c' : '#8a6f2e'} />
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Waveform3D({ isPlaying }) {
|
||||
return (
|
||||
<Canvas
|
||||
camera={{ position: [0, 0.4, 2.2], fov: 45 }}
|
||||
gl={{ alpha: true, antialias: false }}
|
||||
style={{ background: 'transparent' }}
|
||||
>
|
||||
<Waveform isPlaying={isPlaying} />
|
||||
</Canvas>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useRef, useMemo } from 'react'
|
||||
import { Canvas, useFrame } from '@react-three/fiber'
|
||||
import { motion, useInView } from 'framer-motion'
|
||||
import * as THREE from 'three'
|
||||
|
||||
const stats = [
|
||||
{ value: '40M+', label: 'Tracks', sub: 'and growing daily' },
|
||||
{ value: '2.1M', label: 'Artists', sub: 'worldwide' },
|
||||
{ value: '180+', label: 'Countries', sub: 'streaming now' },
|
||||
{ value: '99.9%', label: 'Uptime', sub: 'always on' },
|
||||
]
|
||||
|
||||
const SPIKE_COUNT = 48
|
||||
|
||||
function GlobeSpikes() {
|
||||
const groupRef = useRef()
|
||||
const spikesRef = useRef([])
|
||||
const dummy = useMemo(() => new THREE.Object3D(), [])
|
||||
|
||||
const spikeData = useMemo(() => {
|
||||
return Array.from({ length: SPIKE_COUNT }, (_, i) => {
|
||||
const phi = Math.acos(-1 + (2 * i) / SPIKE_COUNT)
|
||||
const theta = Math.sqrt(SPIKE_COUNT * Math.PI) * phi
|
||||
const r = 1.7
|
||||
return {
|
||||
x: r * Math.sin(phi) * Math.cos(theta),
|
||||
y: r * Math.cos(phi),
|
||||
z: r * Math.sin(phi) * Math.sin(theta),
|
||||
phi, theta,
|
||||
phase: Math.random() * Math.PI * 2,
|
||||
speed: 0.5 + Math.random() * 1.0,
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
const t = clock.getElapsedTime()
|
||||
if (groupRef.current) groupRef.current.rotation.y = t * 0.12
|
||||
|
||||
spikeData.forEach((s, i) => {
|
||||
if (!spikesRef.current[i]) return
|
||||
const len = 0.05 + Math.abs(Math.sin(t * s.speed + s.phase)) * 0.55
|
||||
const dir = new THREE.Vector3(s.x, s.y, s.z).normalize()
|
||||
dummy.position.set(s.x, s.y, s.z)
|
||||
dummy.lookAt(s.x + dir.x, s.y + dir.y, s.z + dir.z)
|
||||
dummy.scale.set(0.02, len, 0.02)
|
||||
dummy.updateMatrix()
|
||||
spikesRef.current[i].setMatrixAt(0, dummy.matrix)
|
||||
spikesRef.current[i].instanceMatrix.needsUpdate = true
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<group ref={groupRef}>
|
||||
{/* Globe wireframe */}
|
||||
<mesh>
|
||||
<sphereGeometry args={[1.7, 24, 24]} />
|
||||
<meshBasicMaterial color="#c9a84c" transparent opacity={0.04} wireframe />
|
||||
</mesh>
|
||||
<mesh>
|
||||
<sphereGeometry args={[1.65, 32, 32]} />
|
||||
<meshBasicMaterial color="#c9a84c" transparent opacity={0.02} />
|
||||
</mesh>
|
||||
|
||||
{/* Spikes as instanced meshes */}
|
||||
{spikeData.map((s, i) => (
|
||||
<instancedMesh key={i} ref={el => spikesRef.current[i] = el} args={[null, null, 1]}>
|
||||
<cylinderGeometry args={[1, 0.1, 1, 4]} />
|
||||
<meshStandardMaterial color="#c9a84c" emissive="#c9a84c" emissiveIntensity={0.8} toneMapped={false} />
|
||||
</instancedMesh>
|
||||
))}
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
export default function StatsSection() {
|
||||
const ref = useRef()
|
||||
const inView = useInView(ref, { once: true, margin: '-100px' })
|
||||
|
||||
return (
|
||||
<section ref={ref} className="py-28 px-6 relative overflow-hidden" style={{ background: '#050505' }}>
|
||||
<div className="section-divider" />
|
||||
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-16 items-center mt-14">
|
||||
{/* Stats */}
|
||||
<div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={inView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="mb-12"
|
||||
>
|
||||
<p className="text-xs tracking-[0.25em] uppercase gold-text mb-3">By The Numbers</p>
|
||||
<h2 className="font-display font-bold text-4xl md:text-5xl text-white">
|
||||
The Scale of <span className="gold-text">Sound</span>
|
||||
</h2>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{stats.map((stat, i) => (
|
||||
<motion.div
|
||||
key={stat.label}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={inView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.5, delay: i * 0.1 }}
|
||||
className="relative p-6 rounded-2xl group"
|
||||
style={{ background: '#101010', border: '1px solid #1e1e1e' }}
|
||||
>
|
||||
<div className="absolute inset-0 rounded-2xl opacity-0 group-hover:opacity-100 transition-opacity duration-300"
|
||||
style={{ boxShadow: 'inset 0 0 0 1px rgba(201,168,76,0.2)' }} />
|
||||
<p className="font-display font-bold text-3xl md:text-4xl gold-text mb-1">{stat.value}</p>
|
||||
<p className="text-white font-medium text-sm">{stat.label}</p>
|
||||
<p className="text-gray-700 text-xs mt-0.5">{stat.sub}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Globe */}
|
||||
<div style={{ height: '420px' }}>
|
||||
<Canvas camera={{ position: [0, 0, 6], fov: 50 }} gl={{ alpha: true, antialias: true }} style={{ background: 'transparent' }}>
|
||||
<ambientLight intensity={0.05} />
|
||||
<pointLight position={[0, 5, 3]} intensity={5} color="#c9a84c" />
|
||||
<pointLight position={[-4, -3, 2]} intensity={2} color="#e8c547" />
|
||||
<pointLight position={[4, 3, -2]} intensity={1.5} color="#8a6f2e" />
|
||||
<GlobeSpikes />
|
||||
</Canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useState } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
export default function TrackRow({ track, index, isActive, onPlay }) {
|
||||
const [hovered, setHovered] = useState(false)
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onClick={() => onPlay(track)}
|
||||
animate={{ x: hovered ? 6 : 0, backgroundColor: hovered ? 'rgba(201,168,76,0.035)' : 'transparent' }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="flex items-center gap-4 px-4 py-3.5 rounded-xl cursor-pointer"
|
||||
style={isActive ? { background: 'rgba(201,168,76,0.06)', border: '1px solid rgba(201,168,76,0.18)' }
|
||||
: { border: '1px solid transparent' }}
|
||||
>
|
||||
{/* Rank / eq bars */}
|
||||
<div className="w-5 flex items-center justify-center flex-shrink-0">
|
||||
{isActive ? (
|
||||
<div className="flex items-end gap-px h-3.5">
|
||||
{[1,2,3].map(i => (
|
||||
<motion.span key={i}
|
||||
animate={{ scaleY: [1, 0.25, 1] }}
|
||||
transition={{ repeat: Infinity, duration: 0.65, delay: i * 0.18, ease: 'easeInOut' }}
|
||||
className="block w-0.5 rounded-full origin-bottom"
|
||||
style={{ height: '100%', background: 'var(--gold)' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs font-mono text-muted">{index + 1}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cover */}
|
||||
<div className="relative w-11 h-11 rounded-lg overflow-hidden flex-shrink-0 shadow-card">
|
||||
<img src={track.cover} alt={track.title}
|
||||
className="w-full h-full object-cover transition-transform duration-300"
|
||||
style={{ transform: hovered ? 'scale(1.12)' : 'scale(1)' }} />
|
||||
{hovered && !isActive && (
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }}
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
style={{ background: 'rgba(7,7,9,0.55)' }}>
|
||||
<svg className="w-3.5 h-3.5 ml-0.5" fill="var(--gold)" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title & artist */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-sm font-medium truncate transition-colors duration-200 ${
|
||||
isActive ? 'gold-text' : hovered ? 'text-white' : 'text-white'
|
||||
}`}>{track.title}</p>
|
||||
<p className="text-xs text-secondary truncate mt-0.5">{track.artist} · {track.album}</p>
|
||||
</div>
|
||||
|
||||
{/* Plays */}
|
||||
<span className="hidden sm:block text-xs text-muted font-mono w-14 text-right">{track.plays}</span>
|
||||
|
||||
{/* Duration */}
|
||||
<span className="text-xs text-secondary font-mono w-9 text-right">{track.duration}</span>
|
||||
|
||||
{/* Action button */}
|
||||
<motion.button
|
||||
animate={{ opacity: hovered ? 1 : 0, scale: hovered ? 1 : 0.75 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
className="w-7 h-7 rounded-full flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: 'rgba(201,168,76,0.12)', border: '1px solid rgba(201,168,76,0.25)' }}
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="var(--gold)" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M12 5l7 7-7 7" />
|
||||
</svg>
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useRef, useEffect } from 'react'
|
||||
import { motion, useInView } from 'framer-motion'
|
||||
import { gsap } from 'gsap'
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger'
|
||||
import TrackRow from './TrackRow'
|
||||
import { tracks } from '../../data/mockData'
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger)
|
||||
|
||||
export default function TrendingTracks({ currentTrack, onPlay }) {
|
||||
const sectionRef = useRef()
|
||||
const ref = useRef()
|
||||
const inView = useInView(ref, { once: true, margin: '-80px' })
|
||||
|
||||
useEffect(() => {
|
||||
const ctx = gsap.context(() => {
|
||||
gsap.from('.track-item', {
|
||||
x: -24,
|
||||
opacity: 0,
|
||||
stagger: 0.06,
|
||||
ease: 'power3.out',
|
||||
scrollTrigger: {
|
||||
trigger: sectionRef.current,
|
||||
start: 'top 78%',
|
||||
},
|
||||
})
|
||||
}, sectionRef)
|
||||
return () => ctx.revert()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<section id="charts" ref={sectionRef} className="py-28 px-6"
|
||||
style={{ background: 'var(--dark-surface)' }}>
|
||||
<div className="section-divider" />
|
||||
|
||||
<div className="max-w-3xl mx-auto" ref={ref}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 28 }}
|
||||
animate={inView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.7 }}
|
||||
className="flex items-end justify-between mb-12 mt-14"
|
||||
>
|
||||
<div>
|
||||
<p className="text-xs tracking-[0.28em] uppercase gold-text mb-3 font-medium">Hot Right Now</p>
|
||||
<h2 className="font-display font-bold text-white leading-tight"
|
||||
style={{ fontSize: 'clamp(2rem, 5vw, 3.5rem)', letterSpacing: '-0.02em' }}>
|
||||
Trending <span className="gold-gradient-text">Tracks</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div className="text-right hidden sm:block">
|
||||
<p className="text-xs text-muted">Updated</p>
|
||||
<p className="text-xs text-secondary font-mono mt-0.5">Weekly</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Column headers */}
|
||||
<div className="flex items-center gap-4 px-4 mb-3 text-xs text-muted uppercase tracking-widest">
|
||||
<span className="w-5 text-center">#</span>
|
||||
<span className="w-11 flex-shrink-0" />
|
||||
<span className="flex-1">Title</span>
|
||||
<span className="hidden sm:block w-14 text-right">Plays</span>
|
||||
<span className="w-9 text-right">Time</span>
|
||||
<span className="w-7" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-0.5">
|
||||
{tracks.map((track, i) => (
|
||||
<div key={track.id} className="track-item">
|
||||
<TrackRow track={track} index={i}
|
||||
isActive={currentTrack?.id === track.id}
|
||||
onPlay={onPlay} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Show more */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={inView ? { opacity: 1 } : {}}
|
||||
transition={{ delay: 0.5 }}
|
||||
className="mt-8 text-center"
|
||||
>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.04, borderColor: 'rgba(201,168,76,0.35)', color: 'var(--gold)' }}
|
||||
className="px-6 py-2.5 rounded-full text-sm text-secondary border border-dark-border transition-all duration-200"
|
||||
>
|
||||
Show More Tracks
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useRef, useMemo } from 'react'
|
||||
import { Canvas, useFrame } from '@react-three/fiber'
|
||||
import { motion } from 'framer-motion'
|
||||
import * as THREE from 'three'
|
||||
|
||||
const COLS = 80
|
||||
const ROWS = 30
|
||||
|
||||
function WaveGrid({ isPlaying }) {
|
||||
const meshRef = useRef()
|
||||
const dummy = useMemo(() => new THREE.Object3D(), [])
|
||||
|
||||
const count = COLS * ROWS
|
||||
|
||||
const basePositions = useMemo(() => {
|
||||
const pos = []
|
||||
for (let r = 0; r < ROWS; r++) {
|
||||
for (let c = 0; c < COLS; c++) {
|
||||
pos.push({
|
||||
x: (c - COLS / 2) * 0.38,
|
||||
z: (r - ROWS / 2) * 0.55,
|
||||
phase: (c * 0.25 + r * 0.4),
|
||||
})
|
||||
}
|
||||
}
|
||||
return pos
|
||||
}, [])
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
if (!meshRef.current) return
|
||||
const t = clock.getElapsedTime()
|
||||
basePositions.forEach((p, i) => {
|
||||
const wave1 = Math.sin(p.phase + t * 1.4) * 0.9
|
||||
const wave2 = Math.sin(p.phase * 0.7 + t * 0.9 + 1.5) * 0.5
|
||||
const y = wave1 + wave2
|
||||
const scale = 0.04 + Math.abs(y) * 0.06
|
||||
dummy.position.set(p.x, y * 0.6, p.z)
|
||||
dummy.scale.setScalar(scale)
|
||||
dummy.updateMatrix()
|
||||
meshRef.current.setMatrixAt(i, dummy.matrix)
|
||||
})
|
||||
meshRef.current.instanceMatrix.needsUpdate = true
|
||||
})
|
||||
|
||||
return (
|
||||
<instancedMesh ref={meshRef} args={[null, null, count]}>
|
||||
<sphereGeometry args={[1, 6, 6]} />
|
||||
<meshStandardMaterial color="#c9a84c" emissive="#c9a84c" emissiveIntensity={0.5} roughness={0.2} metalness={0.8} toneMapped={false} />
|
||||
</instancedMesh>
|
||||
)
|
||||
}
|
||||
|
||||
export default function WaveSection() {
|
||||
return (
|
||||
<section className="relative overflow-hidden" style={{ background: '#050505', height: '500px' }}>
|
||||
<div className="section-divider" />
|
||||
|
||||
<Canvas camera={{ position: [0, 6, 14], fov: 52, near: 0.1, far: 100 }} gl={{ antialias: true, alpha: false }}
|
||||
style={{ background: 'transparent' }}>
|
||||
<ambientLight intensity={0.1} />
|
||||
<pointLight position={[0, 8, 4]} intensity={5} color="#c9a84c" />
|
||||
<pointLight position={[-8, 4, 0]} intensity={2} color="#e8c547" />
|
||||
<pointLight position={[8, 4, 0]} intensity={2} color="#8a6f2e" />
|
||||
<WaveGrid />
|
||||
</Canvas>
|
||||
|
||||
{/* Centered content overlay */}
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
whileInView={{ opacity: 1, scale: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-center glass-gold px-10 py-6 rounded-2xl"
|
||||
style={{ border: '1px solid rgba(201,168,76,0.2)' }}
|
||||
>
|
||||
<p className="text-xs tracking-[0.3em] uppercase gold-text mb-2">Frequency Field</p>
|
||||
<h2 className="font-display font-bold text-3xl md:text-4xl text-white">
|
||||
Every Beat. <span className="gold-text">Visualized.</span>
|
||||
</h2>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="absolute top-0 left-0 right-0 h-24 pointer-events-none" style={{ background: 'linear-gradient(to bottom, #0d0d0d, transparent)' }} />
|
||||
<div className="absolute bottom-0 left-0 right-0 h-24 pointer-events-none" style={{ background: 'linear-gradient(to top, #080808, transparent)' }} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export const tracks = [
|
||||
{ id: 1, title: 'Echoes of Tomorrow', artist: 'Solaris', album: 'Horizon', duration: '3:42', plays: '4.1M', cover: 'https://picsum.photos/seed/tr1/400/400' },
|
||||
{ id: 2, title: 'Midnight Architecture', artist: 'The Void', album: 'Monolith', duration: '4:15', plays: '2.8M', cover: 'https://picsum.photos/seed/tr2/400/400' },
|
||||
{ id: 3, title: 'Weightless', artist: 'AURA', album: 'Ascent', duration: '3:58', plays: '6.3M', cover: 'https://picsum.photos/seed/tr3/400/400' },
|
||||
{ id: 4, title: 'Signal to Noise', artist: 'Parallax', album: 'Infinite Loop', duration: '5:02', plays: '1.9M', cover: 'https://picsum.photos/seed/tr4/400/400' },
|
||||
{ id: 5, title: 'Golden Hour Fade', artist: 'Solaris', album: 'Horizon', duration: '4:33', plays: '3.2M', cover: 'https://picsum.photos/seed/tr5/400/400' },
|
||||
{ id: 6, title: 'Glass Ceiling', artist: 'Echo System', album: 'Phase Shift', duration: '3:21', plays: '1.1M', cover: 'https://picsum.photos/seed/tr6/400/400' },
|
||||
]
|
||||
|
||||
export const albums = [
|
||||
{ id: 1, title: 'Horizon', artist: 'Solaris', year: 2024, genre: 'Ambient', cover: 'https://picsum.photos/seed/al1/600/600', tracks: 12 },
|
||||
{ id: 2, title: 'Monolith', artist: 'The Void', year: 2024, genre: 'Dark Electronic', cover: 'https://picsum.photos/seed/al2/600/600', tracks: 9 },
|
||||
{ id: 3, title: 'Ascent', artist: 'AURA', year: 2023, genre: 'Cinematic', cover: 'https://picsum.photos/seed/al3/600/600', tracks: 11 },
|
||||
{ id: 4, title: 'Infinite Loop', artist: 'Parallax', year: 2024, genre: 'Electronic', cover: 'https://picsum.photos/seed/al4/600/600', tracks: 8 },
|
||||
]
|
||||
|
||||
export const artists = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Solaris',
|
||||
genre: 'Ambient / Cinematic',
|
||||
followers: '5.4M',
|
||||
image: 'https://picsum.photos/seed/art1/800/1000',
|
||||
bio: 'Architect of sound and space. Solaris crafts immersive sonic landscapes that exist between worlds — where silence becomes texture.',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { tracks } from '../data/mockData'
|
||||
|
||||
export function useAudioPlayer() {
|
||||
const [currentTrack, setCurrentTrack] = useState(tracks[0])
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [volume, setVolume] = useState(0.8)
|
||||
|
||||
const play = useCallback((track) => {
|
||||
setCurrentTrack(track)
|
||||
setIsPlaying(true)
|
||||
}, [])
|
||||
|
||||
const togglePlay = useCallback(() => setIsPlaying(p => !p), [])
|
||||
|
||||
const next = useCallback(() => {
|
||||
setCurrentTrack(t => {
|
||||
const idx = tracks.findIndex(x => x.id === t.id)
|
||||
return tracks[(idx + 1) % tracks.length]
|
||||
})
|
||||
}, [])
|
||||
|
||||
const prev = useCallback(() => {
|
||||
setCurrentTrack(t => {
|
||||
const idx = tracks.findIndex(x => x.id === t.id)
|
||||
return tracks[(idx - 1 + tracks.length) % tracks.length]
|
||||
})
|
||||
}, [])
|
||||
|
||||
return { currentTrack, isPlaying, progress, volume, play, togglePlay, next, prev, setProgress, setVolume }
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:ital,wght@0,300;0,400;0,500;0,600;1,300&family=Space+Grotesk:wght@400;500;600;700;800&family=Space+Mono:wght@400;700&display=swap');
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--gold: #c9a84c;
|
||||
--gold-light: #e8c547;
|
||||
--gold-dim: #8a6f2e;
|
||||
--gold-faint: #3d2f0e;
|
||||
--dark-base: #070709;
|
||||
--dark-card: #0f0f12;
|
||||
--dark-surface:#161619;
|
||||
--dark-border: #242428;
|
||||
--text-primary: #f0f0f2;
|
||||
--text-secondary: #7a7a85;
|
||||
--text-muted: #3a3a42;
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--dark-base);
|
||||
color: var(--text-primary);
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
overflow-x: hidden;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ─── Scrollbar ─────────────────────────────── */
|
||||
::-webkit-scrollbar { width: 3px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--gold-dim); border-radius: 2px; }
|
||||
|
||||
/* ─── Selection ─────────────────────────────── */
|
||||
::selection { background: rgba(201,168,76,0.25); color: #fff; }
|
||||
|
||||
/* ─── Glass utilities ───────────────────────── */
|
||||
.glass {
|
||||
background: rgba(255,255,255,0.035);
|
||||
backdrop-filter: blur(28px) saturate(120%);
|
||||
-webkit-backdrop-filter: blur(28px) saturate(120%);
|
||||
border: 1px solid rgba(255,255,255,0.065);
|
||||
}
|
||||
|
||||
.glass-gold {
|
||||
background: rgba(201,168,76,0.055);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(201,168,76,0.18);
|
||||
}
|
||||
|
||||
.glass-dark {
|
||||
background: rgba(7,7,9,0.75);
|
||||
backdrop-filter: blur(28px);
|
||||
-webkit-backdrop-filter: blur(28px);
|
||||
border: 1px solid rgba(255,255,255,0.055);
|
||||
}
|
||||
|
||||
/* ─── Text utilities ────────────────────────── */
|
||||
.gold-text { color: var(--gold); }
|
||||
.gold-gradient-text {
|
||||
background: linear-gradient(135deg, #c9a84c 0%, #e8c547 50%, #c9a84c 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
.text-secondary { color: var(--text-secondary); }
|
||||
.text-muted { color: var(--text-muted); }
|
||||
|
||||
/* ─── Glow utilities ────────────────────────── */
|
||||
.gold-glow { box-shadow: 0 0 24px rgba(201,168,76,0.28), 0 0 64px rgba(201,168,76,0.08); }
|
||||
.gold-glow-sm { box-shadow: 0 0 12px rgba(201,168,76,0.22); }
|
||||
.gold-glow-lg { box-shadow: 0 0 40px rgba(201,168,76,0.35), 0 0 100px rgba(201,168,76,0.1); }
|
||||
|
||||
/* ─── Border utilities ──────────────────────── */
|
||||
.gold-border { border: 1px solid rgba(201,168,76,0.25); }
|
||||
.dim-border { border: 1px solid var(--dark-border); }
|
||||
|
||||
/* ─── Section dividers ──────────────────────── */
|
||||
.section-divider {
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent 0%, rgba(201,168,76,0.2) 50%, transparent 100%);
|
||||
}
|
||||
.section-divider-bright {
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent 0%, rgba(201,168,76,0.45) 50%, transparent 100%);
|
||||
}
|
||||
|
||||
/* ─── Font helpers ──────────────────────────── */
|
||||
.font-mono-num { font-family: 'Space Mono', monospace; font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* ─── Input range ───────────────────────────── */
|
||||
input[type='range'] {
|
||||
-webkit-appearance: none;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: var(--dark-border);
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
input[type='range']::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 11px; height: 11px;
|
||||
border-radius: 50%;
|
||||
background: var(--gold);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 0 6px rgba(201,168,76,0.5);
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
input[type='range']::-webkit-slider-thumb:hover { transform: scale(1.3); }
|
||||
|
||||
/* ─── Horizontal scroll no-bar ──────────────── */
|
||||
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||||
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
|
||||
/* ─── Animations ────────────────────────────── */
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0px); }
|
||||
50% { transform: translateY(-8px); }
|
||||
}
|
||||
@keyframes pulse-gold {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
@keyframes spin-slow {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% center; }
|
||||
100% { background-position: 200% center; }
|
||||
}
|
||||
|
||||
.animate-float { animation: float 3.5s ease-in-out infinite; }
|
||||
.animate-pulse-gold{ animation: pulse-gold 2s ease-in-out infinite; }
|
||||
.animate-spin-slow { animation: spin-slow 12s linear infinite; }
|
||||
.shimmer {
|
||||
background: linear-gradient(90deg, transparent 0%, rgba(201,168,76,0.15) 50%, transparent 100%);
|
||||
background-size: 200% auto;
|
||||
animation: shimmer 2.5s linear infinite;
|
||||
}
|
||||
|
||||
canvas { display: block; }
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.jsx'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
Reference in New Issue
Block a user