mirror of
https://github.com/System-End/My-website.git
synced 2026-04-19 15:18:16 +00:00
feat: add Spotify music integration
This commit is contained in:
parent
5dd4ffe17d
commit
651bb86b52
29 changed files with 1124 additions and 1095 deletions
14
.env.example
14
.env.example
|
|
@ -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
|
||||
99
README.md
99
README.md
|
|
@ -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
|
||||
|
|
@ -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));
|
||||
});
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
7
dev.ps1
7
dev.ps1
|
|
@ -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
1019
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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)
|
||||
};
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
18
src/components/SpotifyEmbed.tsx
Normal file
18
src/components/SpotifyEmbed.tsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
116
src/pages/ParallaxPage.tsx
Normal 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;
|
||||
|
|
@ -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
6
src/utils/dateUtils.ts
Normal 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));
|
||||
}
|
||||
|
|
@ -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))
|
||||
};
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 });
|
||||
};
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue