Made the music integration work, removed unused files, updated readme & .env.example
This commit is contained in:
Unknown 2025-02-11 12:49:33 -07:00 committed by End
parent 75b6c28309
commit c6fe4f09e3
No known key found for this signature in database
29 changed files with 1124 additions and 1095 deletions

View file

@ -1,7 +1,9 @@
# Spotify Configuration
VITE_SPOTIFY_CLIENT_ID=your_client_id_here
VITE_SPOTIFY_CLIENT_SECRET=your_client_secret_here
VITE_SPOTIFY_REDIRECT_URI=http://localhost:3000/callback
# Last.fm Configuration
# Get your API key at: https://www.last.fm/api/account/create
VITE_LASTFM_API_KEY=your_api_key_here
VITE_LASTFM_USERNAME=your_lastfm_username
# API Configuration
VITE_WORKER_URL=http://localhost:8787
# CloudFlare Configuration
# Get these from your Cloudflare dashboard: https://dash.cloudflare.com/
CLOUDFLARE_API_TOKEN=your_cloudflare_api_token
CLOUDFLARE_ACCOUNT_ID=your_cloudflare_account_id

View file

@ -2,11 +2,10 @@
## 🏗️ Architecture Overview
This project implements a modern web application architecture leveraging Cloudflare's edge computing capabilities. The architecture consists of three primary components:
This project implements a modern web application architecture leveraging Cloudflare's edge computing capabilities. The architecture consists of two primary components:
1. **React Frontend**: A Single Page Application (SPA) built with Create React App
2. **Cloudflare Workers**: Serverless functions handling API integrations
3. **Cloudflare Pages**: Static site hosting with global CDN distribution
1. **React Frontend**: A Single Page Application (SPA) built with React and TypeScript
2. **Cloudflare Pages**: Static site hosting with global CDN distribution
## 🚀 Getting Started
@ -15,22 +14,37 @@ This project implements a modern web application architecture leveraging Cloudfl
- Node.js (v16.0.0 or higher)
- npm (v7.0.0 or higher)
- Cloudflare account
- Spotify Developer account
- Last.fm account and API key
- Git
### API Keys Setup
1. **Last.fm API Key**:
- Visit [Last.fm API Account Creation](https://www.last.fm/api/account/create)
- Sign in with your Last.fm account
- Fill in the application details
- Save your API key
- Your username can be found in your profile URL: last.fm/user/YOUR_USERNAME
2. **Cloudflare Setup**:
- Create an account at [Cloudflare](https://dash.cloudflare.com/sign-up)
- Get your Account ID from the dashboard
- Create an API token with Pages deployment permissions
### Environment Setup
1. Clone the repository:
```bash
git clone https://github.com/EndofTimee/My-website
cd personal-website
git clone https://github.com/EndofTimee/personal-site
cd personal-site
```
2. Create a `.env` file in the root directory:
```env
SPOTIFY_CLIENT_ID=your_spotify_client_id
SPOTIFY_CLIENT_SECRET=your_spotify_client_secret
SPOTIFY_REDIRECT_URI=your_redirect_uri
VITE_LASTFM_API_KEY=your_lastfm_api_key
VITE_LASTFM_USERNAME=your_lastfm_username
CLOUDFLARE_API_TOKEN=your_cloudflare_api_token
CLOUDFLARE_ACCOUNT_ID=your_cloudflare_account_id
```
3. Install dependencies:
@ -40,19 +54,12 @@ npm install
## 💻 Local Development
### Starting the Development Server
Start the development server:
```bash
# Start React development server
npm start
# In a separate terminal, start the Cloudflare Worker
npx wrangler dev spotify-worker.js
```
The application will be available at:
- Frontend: http://localhost:3000
- Worker: http://localhost:8787
The application will be available at http://localhost:3000
### Component Structure
@ -61,19 +68,19 @@ The project follows a modular component structure:
```
src/
├── components/
│ ├── SpotifyList/ # Spotify integration
│ ├── GithubRepos/ # GitHub repository display
│ ├── LoadingAnimation/ # Loading states
│ └── ParallaxEffect/ # Visual effects
├── App.js # Main application component
└── index.js # Application entry point
│ ├── LastFMTrack/ # Music integration
│ ├── GithubRepos/ # GitHub repository display
│ ├── LoadingFox/ # Loading states
│ └── ParallaxEffect/ # Visual effects
├── App.tsx # Main application component
└── index.tsx # Application entry point
```
## 🌐 Deployment
### Automated Deployment
The project includes a PowerShell deployment script that handles both frontend and worker deployment:
The project includes a PowerShell deployment script:
```bash
npm run deploy
@ -84,22 +91,15 @@ This script:
2. Installs dependencies
3. Builds the React application
4. Deploys to Cloudflare Pages
5. Deploys the Spotify Worker
6. Sets up environment secrets
5. Sets up environment secrets
### Manual Deployment Steps
If you need to deploy components individually:
If you need to deploy manually:
1. Frontend Deployment:
```bash
npm run build
npx wrangler pages deploy ./build
```
2. Worker Deployment:
```bash
npx wrangler deploy spotify-worker.js
npx wrangler pages deploy ./dist
```
### Environment Configuration
@ -108,34 +108,18 @@ npx wrangler deploy spotify-worker.js
1. Build settings:
- Build command: `npm run build`
- Build output directory: `build`
- Build output directory: `dist`
- Node.js version: 16 (or higher)
2. Environment variables:
- Production branch: `main`
- Preview branches: `dev/*`
#### Worker Configuration:
Required environment secrets:
- `SPOTIFY_CLIENT_ID`
- `SPOTIFY_CLIENT_SECRET`
- `SPOTIFY_REDIRECT_URI`
## 🐛 Troubleshooting
### Common Issues
1. Worker Deployment Failures:
```bash
# Verify wrangler.toml configuration
npx wrangler config
# Check worker status
npx wrangler tail
```
2. Build Issues:
1. Build Issues:
```bash
# Clear dependency cache
rm -rf node_modules
@ -143,7 +127,7 @@ Required environment secrets:
npm install
```
3. Environment Variables:
2. Environment Variables:
```bash
# Verify environment variables
npx wrangler secret list
@ -151,9 +135,8 @@ Required environment secrets:
## 📚 Additional Resources
- [Cloudflare Workers Documentation](https://developers.cloudflare.com/workers/)
- [Cloudflare Pages Documentation](https://developers.cloudflare.com/pages/)
- [Spotify Web API Documentation](https://developer.spotify.com/documentation/web-api/)
- [Last.fm API Documentation](https://www.last.fm/api)
- [React Documentation](https://reactjs.org/docs/getting-started.html)
## 🤝 Contributing
@ -166,4 +149,4 @@ Required environment secrets:
## 📄 License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details

View file

@ -1,50 +0,0 @@
import { Router } from 'itty-router';
const router = Router();
const CLIENT_ID = process.env.CLIENT_ID;
const CLIENT_SECRET = proccess.env.CLIENT_SECRET;
const REDIRECT_URI = proces.env.REDIRECT_URI
let accessToken = null;
// Function to refresh Spotify Access Token
async function refreshAccessToken() {
const authResponse = await fetch('https://accounts.spotify.com/api/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': 'Basic ' + btoa(`${CLIENT_ID}:${CLIENT_SECRET}`)
},
body: 'grant_type=client_credentials'
});
const data = await authResponse.json();
accessToken = data.access_token;
}
// Spotify Data Fetch Endpoint
router.get('/spotify-data', async (request) => {
if (!accessToken) {
await refreshAccessToken();
}
const spotifyResponse = await fetch('https://api.spotify.com/v1/me/top/tracks?limit=10', {
headers: {
'Authorization': `Bearer ${accessToken}`
}
});
const spotifyData = await spotifyResponse.json();
return new Response(JSON.stringify(spotifyData), {
headers: { 'Content-Type': 'application/json' }
});
});
// Default Route
router.all('*', () => new Response('Not Found', { status: 404 }));
// Event Listener for Worker Requests
addEventListener('fetch', (event) => {
event.respondWith(router.handle(event.request));
});

View file

@ -1,5 +1,4 @@
# deploy-master.ps1
#Requires -Version 5.1
# deploy.ps1
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
@ -28,9 +27,6 @@ function Write-Status {
function Test-Environment {
$requiredVars = @{
"SPOTIFY_CLIENT_ID" = ""
"SPOTIFY_CLIENT_SECRET" = ""
"SPOTIFY_REDIRECT_URI" = ""
"CLOUDFLARE_API_TOKEN" = "For Cloudflare API access"
"CLOUDFLARE_ACCOUNT_ID" = "For Cloudflare account identification"
}
@ -47,21 +43,13 @@ function Test-Environment {
}
}
# Check for deprecated variables
if (Get-Item env:CF_API_TOKEN -ErrorAction SilentlyContinue) {
Write-Status "CF_API_TOKEN is deprecated. Please use CLOUDFLARE_API_TOKEN instead" "Warning"
# Automatically migrate the value
$env:CLOUDFLARE_API_TOKEN = $env:CF_API_TOKEN
Remove-Item env:CF_API_TOKEN
}
if ($missingVars.Count -gt 0) {
throw "Missing required environment variables: $($missingVars -join ', ')"
}
}
function Clear-BuildArtifacts {
$paths = @("dist", ".wrangler", "node_modules/.cache")
$paths = @("dist", "node_modules/.cache")
foreach ($path in $paths) {
if (Test-Path $path) {
Remove-Item -Recurse -Force $path
@ -82,12 +70,6 @@ function Install-Dependencies {
function Start-Build {
Write-Status "Building project..." "Info"
Write-Status "Building worker..." "Info"
npm run build:worker
if ($LASTEXITCODE -ne 0) { throw "Worker build failed" }
Write-Status "Building frontend..." "Info"
npm run build
if ($LASTEXITCODE -ne 0) { throw "Frontend build failed" }
@ -95,20 +77,6 @@ function Start-Build {
Write-Status "Build completed successfully" "Success"
}
function Deploy-Worker {
Write-Status "Deploying Cloudflare Worker..." "Info"
# Set wrangler environment variables
$env:WRANGLER_AUTH_TOKEN = $env:CLOUDFLARE_API_TOKEN
$deployEnv = if ($env:CI) { "production" } else { "development" }
npx wrangler deploy src/worker/index.ts --env $deployEnv
if ($LASTEXITCODE -ne 0) { throw "Worker deployment failed" }
Write-Status "Worker deployed successfully" "Success"
}
function Deploy-Frontend {
Write-Status "Deploying to Cloudflare Pages..." "Info"
@ -132,7 +100,6 @@ function Start-Deployment {
Clear-BuildArtifacts
Install-Dependencies
Start-Build
Deploy-Worker
Deploy-Frontend
Write-Status "Deployment completed successfully!" "Success"

View file

@ -58,12 +58,9 @@ function Start-Development {
# Start Vite
$viteWindow = Start-Process powershell -ArgumentList "-NoExit", "-Command", "npm run dev" -PassThru
# Start Wrangler
$wranglerWindow = Start-Process powershell -ArgumentList "-NoExit", "-Command", "npx wrangler dev spotify-worker.ts" -PassThru
Write-Status "Development servers started successfully!" "Success"
Write-Status "Vite running on: http://localhost:3000" "Info"
Write-Status "Worker running on: http://localhost:8787" "Info"
Write-Status "Log file: $logFile" "Info"
# Wait for user input to stop servers
@ -72,14 +69,12 @@ function Start-Development {
# Stop the servers
if ($viteWindow) { Stop-Process -Id $viteWindow.Id -Force }
if ($wranglerWindow) { Stop-Process -Id $wranglerWindow.Id -Force }
Write-Status "Development servers stopped" "Success"
}
catch {
Write-Status "Error during development: $_" "Error"
if ($viteWindow) { Stop-Process -Id $viteWindow.Id -Force }
if ($wranglerWindow) { Stop-Process -Id $wranglerWindow.Id -Force }
exit 1
}
finally {

1019
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"start": "powershell ./dev.ps1",
"dev": "vite",
"dev": "vite --host 0.0.0.0",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",

View file

@ -1,10 +0,0 @@
fetch('/spotify-data')
.then(response => response.json())
.then(data => {
const spotifyList = document.getElementById('spotify-list');
data.items.forEach(track => {
const listItem = document.createElement('li');
listItem.textContent = `${track.name} by ${track.artists.map(artist => artist.name).join(', ')}`;
spotifyList.appendChild(listItem);
});
});

View file

@ -1,83 +0,0 @@
import { Router } from 'itty-router';
const router = Router();
// CORS headers
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
};
// Response helper
const jsonResponse = (data, status = 200) => {
return new Response(JSON.stringify(data), {
status,
headers: {
...corsHeaders,
'Content-Type': 'application/json',
},
});
};
// Error response helper
const errorResponse = (message, status = 500) => {
return jsonResponse({ error: message }, status);
};
// Spotify credentials
const SPOTIFY_CLIENT_ID = process.env.SPOTIFY_CLIENT_ID;
const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET;
const SPOTIFY_REDIRECT_URI = process.env.SPOTIFY_REDIRECT_URI;
// Function to refresh Spotify Access Token
async function refreshAccessToken() {
const authResponse = await fetch('https://accounts.spotify.com/api/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': 'Basic ' + btoa(`${SPOTIFY_CLIENT_ID}:${SPOTIFY_CLIENT_SECRET}`)
},
body: 'grant_type=client_credentials'
});
const data = await authResponse.json();
return data.access_token;
}
// CORS preflight handler
router.options('*', () => new Response(null, { headers: corsHeaders }));
// Health check endpoint
router.get('/health', () => {
return jsonResponse({
status: 'healthy',
timestamp: new Date().toISOString(),
});
});
// Spotify top tracks endpoint
router.get('/top-tracks', async () => {
try {
const accessToken = await refreshAccessToken();
const spotifyResponse = await fetch('https://api.spotify.com/v1/me/top/tracks?limit=10', {
headers: {
'Authorization': `Bearer ${accessToken}`
}
});
const spotifyData = await spotifyResponse.json();
return jsonResponse(spotifyData);
} catch (error) {
return errorResponse('Failed to fetch Spotify data: ' + error.message);
}
});
// 404 handler
router.all('*', () => errorResponse('Not Found', 404));
// Export handler
export default {
fetch: (request, env, ctx) => router.handle(request, env, ctx)
};

View file

@ -1,69 +0,0 @@
import { Router } from 'itty-router'
const router = Router()
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
}
const jsonResponse = (data: any, status = 200) => {
return new Response(JSON.stringify(data), {
status,
headers: {
...corsHeaders,
'Content-Type': 'application/json',
},
})
}
const errorResponse = (message: string, status = 500) => {
return jsonResponse({ error: message }, status)
}
async function refreshAccessToken(env: any) {
const authResponse = await fetch('https://accounts.spotify.com/api/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': 'Basic ' + btoa(`${env.SPOTIFY_CLIENT_ID}:${env.SPOTIFY_CLIENT_SECRET}`)
},
body: 'grant_type=client_credentials'
})
const data = await authResponse.json()
return data.access_token
}
router.options('*', () => new Response(null, { headers: corsHeaders }))
router.get('/health', () => {
return jsonResponse({
status: 'healthy',
timestamp: new Date().toISOString(),
})
})
router.get('/top-tracks', async (request: any, env: any) => {
try {
const accessToken = await refreshAccessToken(env)
const spotifyResponse = await fetch('https://api.spotify.com/v1/me/top/tracks?limit=10', {
headers: {
'Authorization': `Bearer ${accessToken}`
}
})
const spotifyData = await spotifyResponse.json()
return jsonResponse(spotifyData)
} catch (error: any) {
return errorResponse('Failed to fetch Spotify data: ' + error.message)
}
})
router.all('*', () => errorResponse('Not Found', 404))
export default {
fetch: (request: Request, env: any, ctx: any) => router.handle(request, env, ctx)
}

View file

@ -2,6 +2,8 @@ import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
import Navbar from "@/components/Navbar";
import AboutPage from "@/pages/AboutPage";
import ProjectsPage from "@/pages/ProjectsPage";
import APCSPPage from "@/pages/APCSPPage";
import ParallaxPage from "@/pages/ParallaxPage";
const App = () => {
return (
@ -25,6 +27,8 @@ const App = () => {
<Routes>
<Route path="/" element={<AboutPage />} />
<Route path="/projects" element={<ProjectsPage />} />
<Route path="/apcsp" element={<APCSPPage />} />
<Route path="/parallax" element={<ParallaxPage />} />
<Route path="*" element={
<div className="flex flex-col items-center justify-center min-h-[60vh] space-y-4">
<h1 className="text-4xl font-bold text-glow">404: Page Not Found</h1>

View file

@ -1,34 +0,0 @@
import { useEffect, useState } from 'react';
import '@/styles/LoadingAnimation.css';
const generateRandomCode = () => {
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+[]{}|;:,.<>?';
return Array.from({ length: 100 }, () => characters.charAt(Math.floor(Math.random() * characters.length)));
};
const LoadingAnimation = () => {
const [codeLines, setCodeLines] = useState<string[]>([]);
useEffect(() => {
setCodeLines(generateRandomCode());
}, []);
return (
<div className="loading-container">
<div className="loading-background">
{codeLines.map((char, index) => (
<span key={index} className="code-char">{char}</span>
))}
</div>
<div className="loading-blocks">
<div className="block"></div>
<div className="block"></div>
<div className="block"></div>
<div className="block"></div>
<div className="block"></div>
</div>
</div>
);
};
export default LoadingAnimation;

View file

@ -1,88 +1,161 @@
import { useState, useEffect } from "react";
import { Music, Loader } from "lucide-react";
import { useState, useEffect } from 'react';
import { Music, Pause } from 'lucide-react';
interface Track {
name: string;
artist: string;
playlistId?: string;
interface LastFMImage {
'#text': string;
size: string;
}
const PLAYLISTS = [
"58ggvvTcs95yhcSeSxLGks",
"6e2q3GgjEDxMWJBSln2Py5"
];
interface LastFMTrack {
name: string;
artist: {
'#text': string;
};
image: LastFMImage[];
'@attr'?: {
nowplaying: string;
};
}
interface LastFMResponse {
recenttracks: {
track: LastFMTrack[];
};
}
interface CurrentTrack {
name: string;
artist: string;
image: string;
isPlaying: boolean;
}
const MusicDisplay = () => {
const [currentTrack, setCurrentTrack] = useState<Track | null>(null);
const [loading, setLoading] = useState(true);
const [currentTrack, setCurrentTrack] = useState<CurrentTrack | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expanded, setExpanded] = useState(false);
useEffect(() => {
const getRandomPlaylist = () => PLAYLISTS[Math.floor(Math.random() * PLAYLISTS.length)];
useEffect(() => {
const fetchCurrentTrack = async () => {
try {
const API_KEY = import.meta.env.VITE_LASTFM_API_KEY;
const USERNAME = import.meta.env.VITE_LASTFM_USERNAME;
if (!API_KEY || !USERNAME) {
throw new Error('Last.fm API key or username not configured');
}
const url = `https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user=${USERNAME}&api_key=${API_KEY}&format=json&limit=1`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: LastFMResponse = await response.json();
const updateTrack = async () => {
try {
setLoading(true);
const playlistId = getRandomPlaylist();
const track: Track = {
name: "Music from Playlist",
artist: "Current Mix",
playlistId
};
setCurrentTrack(track);
} catch (error) {
console.error("Failed to update track:", error);
setCurrentTrack(null);
} finally {
setLoading(false);
}
};
if (!data.recenttracks?.track?.length) {
setCurrentTrack(null);
setExpanded(true);
return;
}
updateTrack();
const interval = setInterval(updateTrack, 30000);
return () => clearInterval(interval);
}, []);
const track = data.recenttracks.track[0];
const largeImage = track.image.find(img => img.size === 'large');
const imageUrl = largeImage ? largeImage['#text'] :
track.image[track.image.length - 1] ? track.image[track.image.length - 1]['#text'] :
'/placeholder-album.jpg';
if (loading) {
return (
<div className="flex items-center gap-3 p-4">
<Loader size={20} className="animate-spin" />
<span>Loading music...</span>
</div>
);
}
const isCurrentlyPlaying = track['@attr']?.nowplaying === 'true';
if (!currentTrack) {
return (
<div className="flex items-center gap-3 p-4">
<Music size={20} className="text-accent-primary" />
<span>No track available</span>
</div>
);
}
if (!isCurrentlyPlaying) {
setCurrentTrack(null);
setExpanded(true);
return;
}
setCurrentTrack({
name: track.name,
artist: track.artist['#text'],
image: imageUrl,
isPlaying: true
});
setExpanded(false);
setError(null);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'An error occurred';
console.error('Last.fm error:', error);
setError(errorMessage);
} finally {
setLoading(false);
}
};
fetchCurrentTrack();
const interval = setInterval(fetchCurrentTrack, 30000);
return () => clearInterval(interval);
}, []);
if (loading) {
return (
<div className="flex items-center gap-6 p-6 bg-background-secondary/50 rounded-lg">
<div className="relative w-10 h-10">
{[...Array(3)].map((_, i) => (
<div
key={i}
className="absolute bottom-0 w-2 bg-accent-neon animate-float"
style={{
left: `${i * 14}px`,
height: `${Math.random() * 100}%`,
animationDelay: `${i * 0.2}s`
}}
/>
))}
</div>
<div className="space-y-2">
<p className="font-medium text-lg">{currentTrack.name}</p>
<p className="text-sm opacity-80">{currentTrack.artist}</p>
</div>
</div>
<div className="h-[88px] flex items-center justify-center p-4 animate-pulse">
<Music className="w-5 h-5 mr-2" />
<span>Loading music...</span>
</div>
);
}
if (error) {
return (
<div className="h-[88px] flex items-center justify-center p-4 text-red-400">
<span>Unable to load music data: {error}</span>
</div>
);
}
if (!currentTrack) {
return (
<div className={`overflow-hidden transition-[height] duration-500 ease-in-out ${expanded ? 'h-[352px]' : 'h-[88px]'}`}>
<iframe
title="Spotify Playlist"
style={{ borderRadius: '12px' }}
src="https://open.spotify.com/embed/playlist/58ggvvTcs95yhcSeSxLGks?utm_source=generator"
width="100%"
height="352"
frameBorder="0"
allowFullScreen
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
loading="lazy"
/>
</div>
);
}
return (
<div className="h-[88px] flex items-center gap-4 p-4 bg-gradient-card rounded-lg">
<div className="relative w-16 h-16 flex-shrink-0">
<img
src={currentTrack.image}
alt={`${currentTrack.name} album art`}
className="w-full h-full object-cover rounded-md shadow-lg"
/>
<div className="absolute -top-1 -right-1 w-3 h-3">
<span className="absolute w-full h-full bg-accent-neon rounded-full animate-ping"></span>
<span className="absolute w-full h-full bg-accent-neon rounded-full"></span>
</div>
</div>
<div className="flex flex-col min-w-0">
<span className="font-medium truncate">
{currentTrack.name}
</span>
<span className="text-sm opacity-75 truncate">
{currentTrack.artist}
</span>
</div>
</div>
);
};
export default MusicDisplay;
export default MusicDisplay;

View file

@ -1,5 +1,5 @@
import { Link, useLocation } from 'react-router-dom';
import { Home, Code, BookOpen, Twitch } from 'lucide-react';
import { Home, Code, BookOpen, Twitch, Layers } from 'lucide-react';
const Navbar = () => {
const location = useLocation();
@ -7,11 +7,6 @@ const Navbar = () => {
return (
<nav className="navbar">
<div className="nav-content">
<Link to="/" className="nav-brand">
<img src="/logo.jpg" alt="Logo" className="nav-logo" />
<span className="text-glow">EndofTimee</span>
</Link>
<div className="nav-links">
<Link
to="/"
@ -52,4 +47,4 @@ const Navbar = () => {
);
};
export default Navbar;
export default Navbar;

View file

@ -0,0 +1,18 @@
const SpotifyEmbed = () => {
return (
<div className="w-full aspect-[100/35]">
<iframe
src="https://open.spotify.com/embed/playlist/58ggvvTcs95yhcSeSxLGks?utm_source=generator"
width="100%"
height="100%"
style={{ borderRadius: '12px' }}
frameBorder="0"
allowFullScreen
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
loading="lazy"
/>
</div>
);
};
export default SpotifyEmbed;

View file

@ -1,85 +0,0 @@
import { useEffect, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { SpotifyTrack } from '@/types';
interface SpotifyResponse {
items: SpotifyTrack[];
}
function SpotifyList() {
const [tracks, setTracks] = useState<SpotifyTrack[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchTracks = async () => {
try {
setLoading(true);
const response = await fetch(`${import.meta.env.VITE_WORKER_URL}/top-tracks`);
if (!response.ok) {
throw new Error('Failed to fetch tracks');
}
const data = await response.json() as SpotifyResponse;
setTracks(data.items || []);
} catch (error) {
const err = error as Error;
setError(err.message);
console.error('Error fetching tracks:', error);
} finally {
setLoading(false);
}
};
fetchTracks();
}, []);
if (loading) {
return (
<Card className="w-full max-w-md mx-auto">
<CardContent className="p-6">
<div className="flex justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
</div>
</CardContent>
</Card>
);
}
if (error) {
return (
<Card className="w-full max-w-md mx-auto border-red-200">
<CardContent className="p-6">
<p className="text-red-500">Error: {error}</p>
</CardContent>
</Card>
);
}
return (
<Card className="w-full max-w-md mx-auto">
<CardHeader>
<CardTitle>My Top Tracks</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2">
{tracks.map((track, index) => (
<li
key={track.id}
className="p-2 hover:bg-gray-50 rounded-lg transition-colors"
>
<span className="font-medium">{index + 1}.</span>{' '}
{track.name} by{' '}
<span className="text-gray-600">
{track.artists.map(artist => artist.name).join(', ')}
</span>
</li>
))}
</ul>
</CardContent>
</Card>
);
}
export default SpotifyList;

View file

@ -1,66 +0,0 @@
import { useEffect, useRef } from 'react';
import type { SpotifyVisualizerProps } from '@/types';
import '@/styles/SpotifyVisualizer.css';
const SpotifyVisualizer = ({ isPlaying = false }: SpotifyVisualizerProps) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const animationRef = useRef<number | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const bars = 50;
const barWidth = canvas.width / bars;
const animate = () => {
if (!canvas || !ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let i = 0; i < bars; i++) {
const height = isPlaying ?
Math.random() * canvas.height * 0.8 :
canvas.height * 0.1;
const gradient = ctx.createLinearGradient(0, canvas.height, 0, canvas.height - height);
gradient.addColorStop(0, '#9d4edd');
gradient.addColorStop(1, '#b249f8');
ctx.fillStyle = gradient;
ctx.fillRect(
i * barWidth,
canvas.height - height,
barWidth - 2,
height
);
}
animationRef.current = requestAnimationFrame(animate);
};
animate();
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
};
}, [isPlaying]);
return (
<div className="visualizer-container">
<canvas
ref={canvasRef}
width={300}
height={60}
className="music-visualizer"
/>
</div>
);
};
export default SpotifyVisualizer;

View file

@ -1,38 +0,0 @@
import { useState, useEffect } from 'react';
import type { SpotifyTrack } from '@/types';
interface SpotifyResponse {
items: SpotifyTrack[];
}
const useSpotifyData = () => {
const [data, setData] = useState<SpotifyTrack[] | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
const workerUrl = import.meta.env.VITE_WORKER_URL;
if (!workerUrl) throw new Error('Worker URL not configured');
const response = await fetch(`${workerUrl}/spotify-data`);
if (!response.ok) throw new Error('Failed to fetch Spotify data');
const result = await response.json() as SpotifyResponse;
setData(result.items);
} catch (err) {
const error = err as Error;
setError(error.message);
} finally {
setLoading(false);
}
};
fetchData();
}, []);
return { data, loading, error };
};
export default useSpotifyData;

View file

@ -1,28 +1,26 @@
import { Gamepad2, Code, Music } from 'lucide-react';
import { useState, useEffect } from 'react';
import FoxCard from '@/components/FoxCard';
import SpotifyVisualizer from '@/components/SpotifyVisualizer';
import useSpotifyData from '@/hooks/useSpotifyData';
import MusicDisplay from '@/components/MusicDisplay';
import { calculatePreciseAge } from '@/utils/dateUtils';
const AboutPage = () => {
const { data: spotifyData, loading } = useSpotifyData();
const [age, setAge] = useState(calculatePreciseAge(new Date("2009-05-15")));
const calculateAge = (): number => {
const birthDate = new Date("2009-05-15");
const today = new Date();
let age = today.getFullYear() - birthDate.getFullYear();
const monthDiff = today.getMonth() - birthDate.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) {
age--;
}
return age;
};
useEffect(() => {
const interval = setInterval(() => {
setAge(calculatePreciseAge(new Date("2009-05-15")));
}, 50);
return () => clearInterval(interval);
}, []);
return (
<div className="page-container">
<FoxCard className="header-card">
<h1 className="text-glow">About Me</h1>
<p className="text-gradient">
Transfem Foxgirl {calculateAge()} years old Programmer & Streamer
Transfem Foxgirl {age} years old Programmer & Streamer
</p>
</FoxCard>
@ -59,11 +57,11 @@ const AboutPage = () => {
</FoxCard>
<FoxCard>
<div className="flex items-center gap-4">
<div className="flex items-center gap-4 mb-4">
<Music size={24} className="text-accent-primary" />
<h2>Current Tunes</h2>
<h2>Music</h2>
</div>
<SpotifyVisualizer isPlaying={!loading && !!spotifyData} />
<MusicDisplay />
</FoxCard>
</div>
</div>

116
src/pages/ParallaxPage.tsx Normal file
View file

@ -0,0 +1,116 @@
import { useEffect, useState } from 'react';
const ParallaxPage = () => {
const [scrollY, setScrollY] = useState(0);
useEffect(() => {
const handleScroll = () => {
setScrollY(window.scrollY);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
return (
<div className="relative min-h-[200vh] overflow-hidden">
{/* Background Layer */}
<div
className="fixed inset-0 bg-background-primary"
style={{
transform: `translateY(${scrollY * 0.1}px)`,
}}
>
<div className="absolute inset-0 bg-gradient-to-b from-background-primary to-background-secondary opacity-50" />
</div>
{/* Stars Layer */}
<div
className="fixed inset-0"
style={{
transform: `translateY(${scrollY * 0.3}px)`,
}}
>
{[...Array(50)].map((_, i) => (
<div
key={i}
className="absolute bg-white rounded-full animate-pulse"
style={{
width: Math.random() * 3 + 'px',
height: Math.random() * 3 + 'px',
left: `${Math.random() * 100}%`,
top: `${Math.random() * 100}%`,
animationDelay: `${Math.random() * 2}s`,
}}
/>
))}
</div>
{/* Content Layer */}
<div className="relative">
{/* Hero Section */}
<div className="h-screen flex flex-col items-center justify-center text-center px-4 sticky top-0">
<h1
className="text-6xl md:text-8xl font-bold text-glow mb-6"
style={{
transform: `translateY(${scrollY * -0.5}px)`,
opacity: 1 - (scrollY * 0.002),
}}
>
Parallax Magic
</h1>
<p
className="text-xl md:text-2xl text-text-primary/80 max-w-2xl"
style={{
transform: `translateY(${scrollY * -0.3}px)`,
opacity: 1 - (scrollY * 0.002),
}}
>
Scroll down to experience the parallax effect
</p>
</div>
{/* Content Sections */}
<div className="relative bg-background-primary/50 backdrop-blur-lg">
<div className="max-w-4xl mx-auto px-4 py-24">
<div className="space-y-24">
{/* About Section */}
<section
className="space-y-6 p-8 bg-gradient-card rounded-lg"
style={{
transform: `translateX(${(scrollY - 500) * 0.2}px)`,
opacity: Math.min(1, Math.max(0, (scrollY - 500) * 0.002)),
}}
>
<h2 className="text-3xl font-bold text-glow">About Parallax</h2>
<p className="text-lg text-text-primary/80">
Parallax scrolling is a web design technique where background images move slower
than foreground images, creating an illusion of depth and immersion.
</p>
</section>
{/* How It Works Section */}
<section
className="space-y-6 p-8 bg-gradient-card rounded-lg"
style={{
transform: `translateX(${(scrollY - 800) * -0.2}px)`,
opacity: Math.min(1, Math.max(0, (scrollY - 800) * 0.002)),
}}
>
<h2 className="text-3xl font-bold text-glow">How It Works</h2>
<p className="text-lg text-text-primary/80">
As you scroll, different elements move at different speeds. This creates a
dynamic, layered effect that adds depth to the page. The background moves slower
than the foreground elements, while text and content sections slide in from
the sides.
</p>
</section>
</div>
</div>
</div>
</div>
</div>
);
};
export default ParallaxPage;

View file

@ -1,20 +0,0 @@
.visualizer-container {
width: 100%;
max-width: 300px;
height: 60px;
margin: 1rem auto;
background: rgba(26, 11, 46, 0.3);
border-radius: 8px;
overflow: hidden;
}
.music-visualizer {
width: 100%;
height: 100%;
}
@keyframes glow {
0% { filter: drop-shadow(0 0 2px var(--accent-neon)); }
50% { filter: drop-shadow(0 0 8px var(--accent-neon)); }
100% { filter: drop-shadow(0 0 2px var(--accent-neon)); }
}

6
src/utils/dateUtils.ts Normal file
View file

@ -0,0 +1,6 @@
export function calculatePreciseAge(birthDate: Date): number {
const today = new Date();
const diffTime = today.getTime() - birthDate.getTime();
const diffYears = diffTime / (1000 * 60 * 60 * 24 * 365.25);
return Number(diffYears.toFixed(8));
}

View file

@ -1,24 +0,0 @@
// src/worker/index.ts
import { Router } from 'itty-router';
import type { Env } from './types';
import { errorResponse, corsResponse } from './utils/response';
import { spotifyRouter } from './routes/spotify';
import { healthRouter } from './routes/health';
const router = Router();
// CORS preflight
router.options('*', () => corsResponse());
// Mount route handlers
router.get('/health/*', healthRouter.handle);
router.get('/spotify/*', spotifyRouter.handle);
// 404 handler
router.all('*', () => errorResponse('Not Found', 404));
export default {
fetch: (request: Request, env: Env, ctx: ExecutionContext) =>
router.handle(request, env, ctx)
.catch(error => errorResponse(error instanceof Error ? error.message : 'Internal Server Error', 500))
};

View file

@ -1,15 +0,0 @@
// src/worker/routes/health.ts
import { Router } from 'itty-router';
import { jsonResponse } from '../utils/response';
import type { HealthCheckResponse } from '../types';
export const healthRouter = Router({ base: '/health' });
healthRouter.get('/', () => {
const healthCheck: HealthCheckResponse = {
status: 'healthy',
timestamp: new Date().toISOString()
};
return jsonResponse(healthCheck);
});

View file

@ -1,29 +0,0 @@
import { Router } from 'itty-router';
import { jsonResponse, errorResponse } from '../utils/response';
import { getSpotifyToken } from '../utils/spotify';
import type { Env } from '../types';
import type { SpotifyTopTracksResponse } from '../types/spotify';
export const spotifyRouter = Router({ base: '/spotify' });
spotifyRouter.get('/top-tracks', async ({ env }: { env: Env }) => {
try {
const accessToken = await getSpotifyToken(env);
const response = await fetch('https://api.spotify.com/v1/me/top/tracks?limit=10', {
headers: {
'Authorization': `Bearer ${accessToken}`
}
});
if (!response.ok) {
throw new Error(`Failed to fetch tracks: ${response.status}`);
}
const data = await response.json() as SpotifyTopTracksResponse;
return jsonResponse(data);
} catch (error) {
const err = error as Error;
return errorResponse(err.message);
}
});

View file

@ -1,18 +0,0 @@
// src/worker/types/index.ts
export interface Env {
SPOTIFY_CLIENT_ID: string;
SPOTIFY_CLIENT_SECRET: string;
SPOTIFY_REDIRECT_URI: string;
ENVIRONMENT: 'development' | 'production';
}
export interface ApiResponse<T> {
data?: T;
error?: string;
status: number;
}
export interface HealthCheckResponse {
status: 'healthy' | 'unhealthy';
timestamp: string;
}

View file

@ -1,42 +0,0 @@
// src/worker/types/spotify.ts
export interface SpotifyTokenResponse {
access_token: string;
token_type: string;
expires_in: number;
}
export interface SpotifyError {
error: {
status: number;
message: string;
};
}
export interface SpotifyArtist {
name: string;
id: string;
uri: string;
}
export interface SpotifyTrack {
id: string;
name: string;
artists: SpotifyArtist[];
album: {
name: string;
images: Array<{
url: string;
height: number;
width: number;
}>;
};
duration_ms: number;
uri: string;
}
export interface SpotifyTopTracksResponse {
items: SpotifyTrack[];
total: number;
limit: number;
offset: number;
}

View file

@ -1,24 +0,0 @@
// src/worker/utils/response.ts
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
};
export const jsonResponse = <T>(data: T, status = 200): Response => {
return new Response(JSON.stringify(data), {
status,
headers: {
...corsHeaders,
'Content-Type': 'application/json',
},
});
};
export const errorResponse = (message: string, status = 500): Response => {
return jsonResponse({ error: message, status }, status);
};
export const corsResponse = (): Response => {
return new Response(null, { headers: corsHeaders });
};

View file

@ -1,30 +0,0 @@
import type { SpotifyTokenResponse, SpotifyError } from '../types/spotify';
import type { Env } from '../types';
export async function getSpotifyToken(env: Env): Promise<string> {
try {
const response = await fetch('https://accounts.spotify.com/api/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': `Basic ${btoa(`${env.SPOTIFY_CLIENT_ID}:${env.SPOTIFY_CLIENT_SECRET}`)}`
},
body: 'grant_type=client_credentials'
});
if (!response.ok) {
throw new Error(`Failed to get token: ${response.status}`);
}
const data = await response.json() as SpotifyTokenResponse | SpotifyError;
if ('error' in data) {
throw new Error(data.error.message);
}
return data.access_token;
} catch (error) {
const err = error as Error;
throw new Error(`Failed to get Spotify token: ${err.message}`);
}
}