Signed-off-by: mbrucedogs <mbrucedogs@gmail.com>
This commit is contained in:
commit
7e5f2a16a7
139
.gitignore
vendored
Normal file
139
.gitignore
vendored
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||||
|
lib-cov
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||||
|
.grunt
|
||||||
|
|
||||||
|
# Bower dependency directory (https://bower.io/)
|
||||||
|
bower_components
|
||||||
|
|
||||||
|
# node-waf configuration
|
||||||
|
.lock-wscript
|
||||||
|
|
||||||
|
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||||
|
build/Release
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# Snowpack dependency directory (https://snowpack.dev/)
|
||||||
|
web_modules/
|
||||||
|
|
||||||
|
# TypeScript cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Optional stylelint cache
|
||||||
|
.stylelintcache
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
|
||||||
|
# Next.js build output
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
|
||||||
|
# Nuxt.js build / generate output
|
||||||
|
.nuxt
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Gatsby files
|
||||||
|
.cache/
|
||||||
|
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||||
|
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||||
|
# public
|
||||||
|
|
||||||
|
# vuepress build output
|
||||||
|
.vuepress/dist
|
||||||
|
|
||||||
|
# vuepress v2.x temp and cache directory
|
||||||
|
.temp
|
||||||
|
.cache
|
||||||
|
|
||||||
|
# Sveltekit cache directory
|
||||||
|
.svelte-kit/
|
||||||
|
|
||||||
|
# vitepress build output
|
||||||
|
**/.vitepress/dist
|
||||||
|
|
||||||
|
# vitepress cache directory
|
||||||
|
**/.vitepress/cache
|
||||||
|
|
||||||
|
# Docusaurus cache and generated files
|
||||||
|
.docusaurus
|
||||||
|
|
||||||
|
# Serverless directories
|
||||||
|
.serverless/
|
||||||
|
|
||||||
|
# FuseBox cache
|
||||||
|
.fusebox/
|
||||||
|
|
||||||
|
# DynamoDB Local files
|
||||||
|
.dynamodb/
|
||||||
|
|
||||||
|
# Firebase cache directory
|
||||||
|
.firebase/
|
||||||
|
|
||||||
|
# TernJS port file
|
||||||
|
.tern-port
|
||||||
|
|
||||||
|
# Stores VSCode versions used for testing VSCode extensions
|
||||||
|
.vscode-test
|
||||||
|
|
||||||
|
# yarn v3
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/sdks
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# Vite logs files
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
198
README.md
Normal file
198
README.md
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
# Music Charts Archive Scraper
|
||||||
|
|
||||||
|
A full-stack React application for scraping and visualizing music chart data from the Music Charts Archive website.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
MusicCharts/
|
||||||
|
├── docs/
|
||||||
|
├── frontend/ # React frontend application
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── components/ # React components
|
||||||
|
│ │ ├── hooks/ # Custom React hooks
|
||||||
|
│ │ ├── services/ # API service layer
|
||||||
|
│ │ ├── App.jsx # Main app component
|
||||||
|
│ │ ├── index.js # Entry point
|
||||||
|
│ │ └── styles.css # Global styles
|
||||||
|
│ ├── public/
|
||||||
|
│ └── package.json
|
||||||
|
└── backend/ # Node.js/Express backend API
|
||||||
|
├── src/
|
||||||
|
│ ├── routes/ # API routes
|
||||||
|
│ ├── controllers/ # Route controllers
|
||||||
|
│ ├── models/ # Data models & scraping logic
|
||||||
|
│ ├── middleware/ # Express middleware
|
||||||
|
│ └── server.js # Main server file
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Year Selection**: Choose different years to view available chart dates
|
||||||
|
- **Date Selection**: Browse and select specific chart dates for detailed song data
|
||||||
|
- **Music Chart Display**: View ranked songs with title and artist information
|
||||||
|
- **CSV Download**: Export chart data as CSV files
|
||||||
|
- **Responsive Design**: Mobile-friendly interface
|
||||||
|
- **Error Handling**: Comprehensive error states and loading indicators
|
||||||
|
- **Web Scraping**: Real-time data scraping from Music Charts Archive
|
||||||
|
|
||||||
|
## Frontend Components
|
||||||
|
|
||||||
|
- `YearSelector`: Dropdown for year selection
|
||||||
|
- `DateList`: Grid of available chart dates for selected year
|
||||||
|
- `ChartTable`: Music chart data table with rankings
|
||||||
|
- `DownloadButton`: CSV export functionality
|
||||||
|
- `Layout`: Main layout wrapper
|
||||||
|
|
||||||
|
## Backend API Endpoints
|
||||||
|
|
||||||
|
- `GET /api/health` - Health check endpoint
|
||||||
|
- `GET /api/chart/dates/:year` - Get available chart dates for a year
|
||||||
|
- `GET /api/chart/data/:date` - Get chart data for a specific date
|
||||||
|
|
||||||
|
## Setup Instructions
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js (v14 or higher)
|
||||||
|
- npm or yarn
|
||||||
|
|
||||||
|
### Backend Setup
|
||||||
|
|
||||||
|
1. Navigate to the backend directory:
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Install dependencies:
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Create a `.env` file in the backend directory:
|
||||||
|
```env
|
||||||
|
PORT=3001
|
||||||
|
NODE_ENV=development
|
||||||
|
CORS_ORIGIN=http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Start the development server:
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The backend will be available at `http://localhost:3001`
|
||||||
|
|
||||||
|
### Frontend Setup
|
||||||
|
|
||||||
|
1. Navigate to the frontend directory:
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Install dependencies:
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Start the development server:
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
The frontend will be available at `http://localhost:3000`
|
||||||
|
|
||||||
|
## API Documentation
|
||||||
|
|
||||||
|
### Get Available Chart Dates
|
||||||
|
```
|
||||||
|
GET /api/chart/dates/:year
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `year` (number): The year to get chart dates for (1970-present)
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"date": "2024-01-06",
|
||||||
|
"formattedDate": "Jan 6, 2024"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2024-01-13",
|
||||||
|
"formattedDate": "Jan 13, 2024"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Chart Data
|
||||||
|
```
|
||||||
|
GET /api/chart/data/:date
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `date` (string): Date in YYYY-MM-DD format
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"order": 1,
|
||||||
|
"title": "Lovin On Me",
|
||||||
|
"artist": "Jack Harlow"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 2,
|
||||||
|
"title": "Cruel Summer [re-release]",
|
||||||
|
"artist": "Taylor Swift"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Source
|
||||||
|
|
||||||
|
This application scrapes data from [Music Charts Archive](https://musicchartsarchive.com), a comprehensive database of historical music charts. The scraper extracts:
|
||||||
|
|
||||||
|
1. **Chart Dates**: Available chart dates for each year
|
||||||
|
2. **Song Rankings**: Top songs with their chart positions, titles, and artists
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Adding New Features
|
||||||
|
|
||||||
|
1. **Frontend**: Add new components in `frontend/src/components/`
|
||||||
|
2. **Backend**: Add new routes in `backend/src/routes/` and controllers in `backend/src/controllers/`
|
||||||
|
3. **Scraping**: Modify `backend/src/models/chartService.js` for new data extraction
|
||||||
|
4. **Styling**: Modify `frontend/src/styles.css` for global styles
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- Backend tests: `cd backend && npm test`
|
||||||
|
- Frontend tests: `cd frontend && npm test`
|
||||||
|
|
||||||
|
## Technologies Used
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- React 18
|
||||||
|
- Custom Hooks
|
||||||
|
- CSS3 with modern features
|
||||||
|
- Axios for API calls
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- Node.js
|
||||||
|
- Express.js
|
||||||
|
- Cheerio for HTML parsing
|
||||||
|
- Axios for web scraping
|
||||||
|
- CORS for cross-origin requests
|
||||||
|
- Helmet for security headers
|
||||||
|
- Morgan for logging
|
||||||
|
|
||||||
|
## Legal Notice
|
||||||
|
|
||||||
|
This application is for educational and research purposes. Please respect the terms of service of the Music Charts Archive website and implement appropriate rate limiting and caching strategies for production use.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License
|
||||||
5245
backend/package-lock.json
generated
Normal file
5245
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
backend/package.json
Normal file
27
backend/package.json
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "chart-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Backend API service for music chart data scraping",
|
||||||
|
"main": "src/server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/server.js",
|
||||||
|
"dev": "nodemon src/server.js",
|
||||||
|
"test": "jest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"helmet": "^7.1.0",
|
||||||
|
"morgan": "^1.10.0",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
|
"axios": "^1.6.0",
|
||||||
|
"cheerio": "^1.0.0-rc.12"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.0.1",
|
||||||
|
"jest": "^29.7.0"
|
||||||
|
},
|
||||||
|
"keywords": ["api", "charts", "music", "scraping", "express"],
|
||||||
|
"author": "",
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
BIN
backend/src/.DS_Store
vendored
Normal file
BIN
backend/src/.DS_Store
vendored
Normal file
Binary file not shown.
71
backend/src/controllers/chartController.js
Normal file
71
backend/src/controllers/chartController.js
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
const ChartService = require('../models/chartService');
|
||||||
|
|
||||||
|
// Get available dates for a specific year
|
||||||
|
const getChartDates = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { year } = req.params;
|
||||||
|
|
||||||
|
// Validate year parameter
|
||||||
|
const validatedYear = ChartService.validateYear(year);
|
||||||
|
|
||||||
|
const dates = await ChartService.getAvailableDates(validatedYear);
|
||||||
|
res.json(dates);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in getChartDates:', error);
|
||||||
|
res.status(400).json({
|
||||||
|
error: 'Failed to fetch chart dates',
|
||||||
|
message: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get chart data for a specific date
|
||||||
|
const getChartData = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { date } = req.params;
|
||||||
|
|
||||||
|
if (!date) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Invalid date parameter',
|
||||||
|
message: 'Date parameter is required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate date format
|
||||||
|
ChartService.validateDate(date);
|
||||||
|
|
||||||
|
const data = await ChartService.getChartData(date);
|
||||||
|
res.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in getChartData:', error);
|
||||||
|
res.status(400).json({
|
||||||
|
error: 'Failed to fetch chart data',
|
||||||
|
message: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get yearly top songs
|
||||||
|
const getYearlyTopSongs = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { year } = req.params;
|
||||||
|
|
||||||
|
// Validate year parameter
|
||||||
|
const validatedYear = ChartService.validateYear(year);
|
||||||
|
|
||||||
|
const yearlySongs = await ChartService.getYearlyTopSongs(validatedYear);
|
||||||
|
res.json(yearlySongs);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in getYearlyTopSongs:', error);
|
||||||
|
res.status(400).json({
|
||||||
|
error: 'Failed to fetch yearly top songs',
|
||||||
|
message: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getChartDates,
|
||||||
|
getChartData,
|
||||||
|
getYearlyTopSongs
|
||||||
|
};
|
||||||
35
backend/src/middleware/errorHandler.js
Normal file
35
backend/src/middleware/errorHandler.js
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
const errorHandler = (err, req, res, next) => {
|
||||||
|
console.error(err.stack);
|
||||||
|
|
||||||
|
// Default error
|
||||||
|
let error = {
|
||||||
|
message: err.message || 'Internal Server Error',
|
||||||
|
status: err.status || 500
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mongoose validation error
|
||||||
|
if (err.name === 'ValidationError') {
|
||||||
|
error.message = Object.values(err.errors).map(val => val.message).join(', ');
|
||||||
|
error.status = 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mongoose duplicate key error
|
||||||
|
if (err.code === 11000) {
|
||||||
|
error.message = 'Duplicate field value entered';
|
||||||
|
error.status = 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mongoose cast error
|
||||||
|
if (err.name === 'CastError') {
|
||||||
|
error.message = 'Resource not found';
|
||||||
|
error.status = 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(error.status).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = errorHandler;
|
||||||
208
backend/src/models/chartService.js
Normal file
208
backend/src/models/chartService.js
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
const cheerio = require('cheerio');
|
||||||
|
|
||||||
|
class ChartService {
|
||||||
|
static baseUrl = 'https://musicchartsarchive.com';
|
||||||
|
|
||||||
|
// Get available dates for a specific year by scraping the HTML
|
||||||
|
static async getAvailableDates(year) {
|
||||||
|
try {
|
||||||
|
const url = `${this.baseUrl}/singles-charts/${year}`;
|
||||||
|
console.log(`Scraping dates from: ${url}`);
|
||||||
|
|
||||||
|
const response = await axios.get(url, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
|
||||||
|
},
|
||||||
|
timeout: 30000
|
||||||
|
});
|
||||||
|
|
||||||
|
const $ = cheerio.load(response.data);
|
||||||
|
const dates = [];
|
||||||
|
|
||||||
|
// Find all chart date links in the format: <a href="/singles-chart/2024-04-06">Apr 6, 2024</a>
|
||||||
|
$('a[href^="/singles-chart/"]').each((index, element) => {
|
||||||
|
const href = $(element).attr('href');
|
||||||
|
const text = $(element).text().trim();
|
||||||
|
|
||||||
|
// Extract date from href (e.g., "/singles-chart/2024-04-06" -> "2024-04-06")
|
||||||
|
const dateMatch = href.match(/\/singles-chart\/(\d{4}-\d{2}-\d{2})/);
|
||||||
|
if (dateMatch) {
|
||||||
|
const date = dateMatch[1];
|
||||||
|
dates.push({
|
||||||
|
date: date,
|
||||||
|
formattedDate: text
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort dates chronologically
|
||||||
|
dates.sort((a, b) => new Date(a.date) - new Date(b.date));
|
||||||
|
|
||||||
|
console.log(`Found ${dates.length} dates for year ${year}`);
|
||||||
|
return dates;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error scraping dates for year ${year}:`, error.message);
|
||||||
|
throw new Error(`Failed to fetch available dates for year ${year}: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get chart data for a specific date by scraping the HTML
|
||||||
|
static async getChartData(date) {
|
||||||
|
try {
|
||||||
|
const url = `${this.baseUrl}/singles-chart/${date}`;
|
||||||
|
console.log(`Scraping chart data from: ${url}`);
|
||||||
|
|
||||||
|
const response = await axios.get(url, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
|
||||||
|
},
|
||||||
|
timeout: 30000
|
||||||
|
});
|
||||||
|
|
||||||
|
const $ = cheerio.load(response.data);
|
||||||
|
const songs = [];
|
||||||
|
|
||||||
|
// Find all table rows in the chart table
|
||||||
|
$('.chart-table tr').each((index, element) => {
|
||||||
|
const $row = $(element);
|
||||||
|
const $cells = $row.find('td');
|
||||||
|
|
||||||
|
// Skip header rows or rows without enough cells
|
||||||
|
if ($cells.length < 3) return;
|
||||||
|
|
||||||
|
const order = $cells.eq(0).text().trim();
|
||||||
|
const titleLink = $cells.eq(1).find('a');
|
||||||
|
const artistLink = $cells.eq(2).find('a');
|
||||||
|
|
||||||
|
// Skip if no order number (likely header row)
|
||||||
|
if (!order || isNaN(parseInt(order))) return;
|
||||||
|
|
||||||
|
const title = titleLink.text().trim();
|
||||||
|
const artist = artistLink.text().trim();
|
||||||
|
|
||||||
|
// Only add if we have valid data
|
||||||
|
if (title && artist) {
|
||||||
|
songs.push({
|
||||||
|
order: parseInt(order),
|
||||||
|
title: title,
|
||||||
|
artist: artist
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Found ${songs.length} songs for date ${date}`);
|
||||||
|
return songs;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error scraping chart data for date ${date}:`, error.message);
|
||||||
|
throw new Error(`Failed to fetch chart data for date ${date}: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get yearly top songs by analyzing all chart data for a year
|
||||||
|
static async getYearlyTopSongs(year) {
|
||||||
|
try {
|
||||||
|
console.log(`Calculating yearly top songs for ${year}`);
|
||||||
|
|
||||||
|
// Get all available dates for the year
|
||||||
|
const dates = await this.getAvailableDates(year);
|
||||||
|
|
||||||
|
if (dates.length === 0) {
|
||||||
|
throw new Error(`No chart data available for year ${year}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const songFrequency = {};
|
||||||
|
|
||||||
|
// Process each date to collect song data
|
||||||
|
console.log(`Processing ${dates.length} dates for yearly calculation...`);
|
||||||
|
for (let i = 0; i < dates.length; i++) {
|
||||||
|
const dateObj = dates[i];
|
||||||
|
console.log(`Processing date ${i + 1}/${dates.length}: ${dateObj.date}`);
|
||||||
|
try {
|
||||||
|
const songs = await this.getChartData(dateObj.date);
|
||||||
|
|
||||||
|
songs.forEach(song => {
|
||||||
|
const key = `${song.title}-${song.artist}`;
|
||||||
|
|
||||||
|
if (!songFrequency[key]) {
|
||||||
|
songFrequency[key] = {
|
||||||
|
title: song.title,
|
||||||
|
artist: song.artist,
|
||||||
|
appearances: 0,
|
||||||
|
highestPosition: 999,
|
||||||
|
totalPoints: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
songFrequency[key].appearances++;
|
||||||
|
songFrequency[key].highestPosition = Math.min(songFrequency[key].highestPosition, song.order);
|
||||||
|
// Reverse scoring: 1st place = 50 points, 50th place = 1 point
|
||||||
|
songFrequency[key].totalPoints += (51 - song.order);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add a small delay between requests to be respectful to the server
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Skipping date ${dateObj.date} due to error: ${error.message}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to array and sort by total points (descending)
|
||||||
|
const yearlySongs = Object.values(songFrequency)
|
||||||
|
.sort((a, b) => {
|
||||||
|
// Primary sort: total points
|
||||||
|
if (b.totalPoints !== a.totalPoints) {
|
||||||
|
return b.totalPoints - a.totalPoints;
|
||||||
|
}
|
||||||
|
// Secondary sort: highest position (lower is better)
|
||||||
|
if (a.highestPosition !== b.highestPosition) {
|
||||||
|
return a.highestPosition - b.highestPosition;
|
||||||
|
}
|
||||||
|
// Tertiary sort: number of appearances
|
||||||
|
return b.appearances - a.appearances;
|
||||||
|
})
|
||||||
|
.slice(0, 50) // Get top 50
|
||||||
|
.map((song, index) => ({
|
||||||
|
order: index + 1,
|
||||||
|
title: song.title,
|
||||||
|
artist: song.artist,
|
||||||
|
totalPoints: song.totalPoints,
|
||||||
|
highestPosition: song.highestPosition,
|
||||||
|
appearances: song.appearances
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log(`Calculated yearly top ${yearlySongs.length} songs for ${year}`);
|
||||||
|
return yearlySongs;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error calculating yearly top songs for ${year}:`, error.message);
|
||||||
|
throw new Error(`Failed to calculate yearly top songs for ${year}: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate date format (YYYY-MM-DD)
|
||||||
|
static validateDate(date) {
|
||||||
|
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
if (!dateRegex.test(date)) {
|
||||||
|
throw new Error('Invalid date format. Expected YYYY-MM-DD');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedDate = new Date(date);
|
||||||
|
if (isNaN(parsedDate.getTime())) {
|
||||||
|
throw new Error('Invalid date');
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate year format
|
||||||
|
static validateYear(year) {
|
||||||
|
const yearNum = parseInt(year);
|
||||||
|
if (isNaN(yearNum) || yearNum < 1970 || yearNum > new Date().getFullYear() + 1) {
|
||||||
|
throw new Error('Invalid year. Must be between 1970 and current year + 1');
|
||||||
|
}
|
||||||
|
return yearNum;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = ChartService;
|
||||||
15
backend/src/routes/chartRoutes.js
Normal file
15
backend/src/routes/chartRoutes.js
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const chartController = require('../controllers/chartController');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Get available dates for a specific year
|
||||||
|
router.get('/dates/:year', chartController.getChartDates);
|
||||||
|
|
||||||
|
// Get chart data for a specific date
|
||||||
|
router.get('/data/:date', chartController.getChartData);
|
||||||
|
|
||||||
|
// Get yearly top songs
|
||||||
|
router.get('/yearly-top/:year', chartController.getYearlyTopSongs);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
44
backend/src/server.js
Normal file
44
backend/src/server.js
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
const helmet = require('helmet');
|
||||||
|
const morgan = require('morgan');
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const chartRoutes = require('./routes/chartRoutes');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3001;
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
app.use(helmet());
|
||||||
|
app.use(cors());
|
||||||
|
app.use(morgan('combined'));
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
|
// Routes
|
||||||
|
app.use('/api/chart', chartRoutes);
|
||||||
|
|
||||||
|
// Health check endpoint
|
||||||
|
app.get('/api/health', (req, res) => {
|
||||||
|
res.json({ status: 'OK', timestamp: new Date().toISOString() });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Error handling middleware
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
console.error(err.stack);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Something went wrong!',
|
||||||
|
message: process.env.NODE_ENV === 'development' ? err.message : 'Internal server error'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 404 handler
|
||||||
|
app.use('*', (req, res) => {
|
||||||
|
res.status(404).json({ error: 'Route not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`Server running on port ${PORT}`);
|
||||||
|
console.log(`Health check: http://localhost:${PORT}/api/health`);
|
||||||
|
});
|
||||||
401
docs/PRD.md
Normal file
401
docs/PRD.md
Normal file
@ -0,0 +1,401 @@
|
|||||||
|
# Product Requirements Document (PRD)
|
||||||
|
## Music Charts Archive Scraper & Analytics Platform
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 **Executive Summary**
|
||||||
|
|
||||||
|
A full-stack web application that scrapes, analyzes, and visualizes music chart data from the Music Charts Archive website. The platform provides both weekly chart views and comprehensive yearly rankings, enabling users to explore historical music trends and download data for further analysis.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 **Product Vision**
|
||||||
|
|
||||||
|
Create a comprehensive music analytics platform that transforms raw chart data into actionable insights, making historical music trends accessible and analyzable for researchers, music enthusiasts, and data analysts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎵 **Core Features**
|
||||||
|
|
||||||
|
### **1. Data Scraping Engine**
|
||||||
|
- **Weekly Chart Scraping**: Extract song rankings from individual chart dates
|
||||||
|
- **Yearly Data Aggregation**: Collect and analyze data across all available dates for a year
|
||||||
|
- **Real-time Data Fetching**: Live scraping from Music Charts Archive website
|
||||||
|
- **Error Handling**: Graceful handling of missing data and network issues
|
||||||
|
|
||||||
|
### **2. Weekly Chart View**
|
||||||
|
- **Date Selection**: Browse available chart dates by year
|
||||||
|
- **Chart Display**: View top 50 songs with rankings, titles, and artists
|
||||||
|
- **Data Export**: Download weekly chart data in JSON format
|
||||||
|
- **Responsive Design**: Mobile and desktop optimized interface
|
||||||
|
|
||||||
|
### **3. Yearly Analytics**
|
||||||
|
- **Yearly Rankings**: Calculate top songs of the year based on:
|
||||||
|
- Total points (position-based scoring)
|
||||||
|
- Highest achieved position
|
||||||
|
- Number of appearances
|
||||||
|
- **Smart Algorithm**: Multi-factor ranking system
|
||||||
|
- **Yearly Export**: Download comprehensive yearly data
|
||||||
|
|
||||||
|
### **4. Data Export System**
|
||||||
|
- **JSON Format**: Structured data export with metadata
|
||||||
|
- **File Naming**: Date/year-based file naming convention
|
||||||
|
- **Metadata Inclusion**: Chart date, total songs, formatted titles
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 **Technical Architecture**
|
||||||
|
|
||||||
|
### **Backend Requirements**
|
||||||
|
|
||||||
|
#### **Core Technologies (Current: Node.js/Express)**
|
||||||
|
- **Server Framework**: Node.js/Express, Python/Flask, Java/Spring Boot, C#/.NET, Go/Gin
|
||||||
|
- **Web Scraping**: Cheerio (Node.js), BeautifulSoup (Python), Jsoup (Java), HtmlAgilityPack (C#), goquery (Go)
|
||||||
|
- **HTTP Client**: Axios (Node.js), requests (Python), OkHttp (Java), HttpClient (C#), net/http (Go)
|
||||||
|
- **Data Processing**: JavaScript, Python, Java, C#, Go
|
||||||
|
|
||||||
|
#### **API Endpoints**
|
||||||
|
```
|
||||||
|
GET /api/health
|
||||||
|
- Purpose: Health check endpoint
|
||||||
|
- Response: Status confirmation
|
||||||
|
|
||||||
|
GET /api/chart/dates/:year
|
||||||
|
- Purpose: Get available chart dates for a year
|
||||||
|
- Parameters: year (number)
|
||||||
|
- Response: Array of date objects with date and formattedDate
|
||||||
|
|
||||||
|
GET /api/chart/data/:date
|
||||||
|
- Purpose: Get chart data for specific date
|
||||||
|
- Parameters: date (YYYY-MM-DD format)
|
||||||
|
- Response: Array of song objects with order, title, artist
|
||||||
|
|
||||||
|
GET /api/chart/yearly-top/:year
|
||||||
|
- Purpose: Get yearly top songs ranking
|
||||||
|
- Parameters: year (number)
|
||||||
|
- Response: Array of top 50 songs with order, title, artist
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **Data Models**
|
||||||
|
```javascript
|
||||||
|
// Date Object
|
||||||
|
{
|
||||||
|
date: "2024-01-20",
|
||||||
|
formattedDate: "Jan 20, 2024"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Song Object
|
||||||
|
{
|
||||||
|
order: 1,
|
||||||
|
title: "Lovin On Me",
|
||||||
|
artist: "Jack Harlow"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weekly Chart Export
|
||||||
|
{
|
||||||
|
title: "January 20, 2024 - Top Songs",
|
||||||
|
date: "2024-01-20",
|
||||||
|
totalSongs: 50,
|
||||||
|
songs: [Song Object Array]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yearly Chart Export
|
||||||
|
{
|
||||||
|
year: 2024,
|
||||||
|
title: "Top Songs of 2024",
|
||||||
|
songs: [Song Object Array]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **Scraping Logic**
|
||||||
|
1. **Date Extraction**: Parse HTML for chart date links
|
||||||
|
2. **Song Data Extraction**: Parse table rows for song information
|
||||||
|
3. **Data Validation**: Ensure data integrity and completeness
|
||||||
|
4. **Error Recovery**: Skip problematic dates and continue processing
|
||||||
|
|
||||||
|
#### **Yearly Ranking Algorithm**
|
||||||
|
```javascript
|
||||||
|
// Scoring System
|
||||||
|
- Position Points: 1st = 50 points, 50th = 1 point
|
||||||
|
- Highest Position: Track best position achieved
|
||||||
|
- Appearance Count: Number of weeks on charts
|
||||||
|
|
||||||
|
// Sorting Priority
|
||||||
|
1. Total Points (descending)
|
||||||
|
2. Highest Position (ascending)
|
||||||
|
3. Appearance Count (descending)
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Frontend Requirements**
|
||||||
|
|
||||||
|
#### **Core Technologies (Current: React)**
|
||||||
|
- **UI Framework**: React, Vue.js, Angular, Svelte, Flutter (mobile), SwiftUI (iOS), Jetpack Compose (Android)
|
||||||
|
- **State Management**: React Hooks, Vuex, Redux, NgRx, Svelte stores
|
||||||
|
- **HTTP Client**: Axios, fetch API, HttpClient
|
||||||
|
- **Styling**: CSS3, SCSS, Tailwind CSS, Material-UI, Bootstrap
|
||||||
|
|
||||||
|
#### **Component Architecture**
|
||||||
|
```
|
||||||
|
App
|
||||||
|
├── Layout
|
||||||
|
├── YearSelector
|
||||||
|
├── ViewModeSelector (Weekly/Yearly)
|
||||||
|
├── DateList (Weekly mode)
|
||||||
|
├── ChartTable
|
||||||
|
├── YearlyTopSongs
|
||||||
|
├── DownloadButton
|
||||||
|
└── YearlyDownloadButton
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **State Management**
|
||||||
|
```javascript
|
||||||
|
// Core State
|
||||||
|
{
|
||||||
|
selectedYear: number,
|
||||||
|
selectedDate: string | null,
|
||||||
|
viewMode: 'weekly' | 'yearly',
|
||||||
|
dates: DateObject[],
|
||||||
|
chartData: SongObject[],
|
||||||
|
yearlySongs: SongObject[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading States
|
||||||
|
{
|
||||||
|
datesLoading: boolean,
|
||||||
|
dataLoading: boolean,
|
||||||
|
yearlyLoading: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error States
|
||||||
|
{
|
||||||
|
datesError: string | null,
|
||||||
|
dataError: string | null,
|
||||||
|
yearlyError: string | null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **User Interface Requirements**
|
||||||
|
- **Responsive Design**: Mobile-first approach
|
||||||
|
- **Loading States**: Visual feedback during data fetching
|
||||||
|
- **Error Handling**: User-friendly error messages
|
||||||
|
- **Accessibility**: WCAG 2.1 compliance
|
||||||
|
- **Cross-browser Compatibility**: Modern browser support
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📱 **Mobile Platform Considerations**
|
||||||
|
|
||||||
|
### **iOS (Swift/SwiftUI)**
|
||||||
|
```swift
|
||||||
|
// Data Models
|
||||||
|
struct ChartDate: Codable {
|
||||||
|
let date: String
|
||||||
|
let formattedDate: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Song: Codable {
|
||||||
|
let order: Int
|
||||||
|
let title: String
|
||||||
|
let artist: String
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network Layer
|
||||||
|
class ChartAPIService {
|
||||||
|
func fetchChartDates(year: Int) async throws -> [ChartDate]
|
||||||
|
func fetchChartData(date: String) async throws -> [Song]
|
||||||
|
func fetchYearlyTopSongs(year: Int) async throws -> [Song]
|
||||||
|
}
|
||||||
|
|
||||||
|
// UI Components
|
||||||
|
struct YearSelector: View
|
||||||
|
struct ChartTableView: View
|
||||||
|
struct YearlyTopSongsView: View
|
||||||
|
struct DownloadButton: View
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Android (Kotlin/Jetpack Compose)**
|
||||||
|
```kotlin
|
||||||
|
// Data Models
|
||||||
|
data class ChartDate(
|
||||||
|
val date: String,
|
||||||
|
val formattedDate: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class Song(
|
||||||
|
val order: Int,
|
||||||
|
val title: String,
|
||||||
|
val artist: String
|
||||||
|
)
|
||||||
|
|
||||||
|
// Network Layer
|
||||||
|
class ChartAPIService {
|
||||||
|
suspend fun fetchChartDates(year: Int): List<ChartDate>
|
||||||
|
suspend fun fetchChartData(date: String): List<Song>
|
||||||
|
suspend fun fetchYearlyTopSongs(year: Int): List<Song>
|
||||||
|
}
|
||||||
|
|
||||||
|
// UI Components
|
||||||
|
@Composable fun YearSelector()
|
||||||
|
@Composable fun ChartTable()
|
||||||
|
@Composable fun YearlyTopSongs()
|
||||||
|
@Composable fun DownloadButton()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎵 **Data Flow**
|
||||||
|
|
||||||
|
### **Weekly Chart Flow**
|
||||||
|
1. User selects year → Fetch available dates
|
||||||
|
2. User selects date → Fetch chart data
|
||||||
|
3. Display chart table → Enable download
|
||||||
|
4. User downloads → Generate JSON file
|
||||||
|
|
||||||
|
### **Yearly Chart Flow**
|
||||||
|
1. User selects year → Fetch all dates for year
|
||||||
|
2. System scrapes all dates → Calculate rankings
|
||||||
|
3. Display yearly top songs → Enable download
|
||||||
|
4. User downloads → Generate yearly JSON file
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠 **Development Guidelines**
|
||||||
|
|
||||||
|
### **Backend Development**
|
||||||
|
1. **Error Handling**: Implement comprehensive error handling
|
||||||
|
2. **Rate Limiting**: Respect website terms of service
|
||||||
|
3. **Caching**: Implement caching for frequently accessed data
|
||||||
|
4. **Logging**: Comprehensive logging for debugging
|
||||||
|
5. **Testing**: Unit tests for core algorithms
|
||||||
|
|
||||||
|
### **Frontend Development**
|
||||||
|
1. **Component Reusability**: Create reusable UI components
|
||||||
|
2. **State Management**: Centralized state management
|
||||||
|
3. **Performance**: Optimize for large datasets
|
||||||
|
4. **User Experience**: Smooth loading and error states
|
||||||
|
5. **Accessibility**: Screen reader and keyboard navigation support
|
||||||
|
|
||||||
|
### **Mobile Development**
|
||||||
|
1. **Offline Support**: Cache data for offline viewing
|
||||||
|
2. **Native Features**: Share functionality, file downloads
|
||||||
|
3. **Performance**: Optimize for mobile devices
|
||||||
|
4. **Platform Guidelines**: Follow iOS/Android design guidelines
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 **Success Metrics**
|
||||||
|
|
||||||
|
### **Technical Metrics**
|
||||||
|
- **API Response Time**: < 2 seconds for chart data
|
||||||
|
- **Scraping Success Rate**: > 95% successful data extraction
|
||||||
|
- **Error Rate**: < 5% failed requests
|
||||||
|
- **Uptime**: > 99% availability
|
||||||
|
|
||||||
|
### **User Experience Metrics**
|
||||||
|
- **Page Load Time**: < 3 seconds
|
||||||
|
- **User Engagement**: Time spent on platform
|
||||||
|
- **Download Rate**: Percentage of users downloading data
|
||||||
|
- **Error Recovery**: Successful error resolution rate
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔒 **Security & Compliance**
|
||||||
|
|
||||||
|
### **Data Protection**
|
||||||
|
- **Rate Limiting**: Prevent abuse of scraping endpoints
|
||||||
|
- **User Agent**: Proper identification in requests
|
||||||
|
- **Error Handling**: No sensitive data in error messages
|
||||||
|
- **CORS**: Proper cross-origin resource sharing
|
||||||
|
|
||||||
|
### **Legal Considerations**
|
||||||
|
- **Terms of Service**: Respect Music Charts Archive terms
|
||||||
|
- **Data Usage**: Educational and research purposes
|
||||||
|
- **Attribution**: Proper credit to data source
|
||||||
|
- **Rate Limiting**: Responsible scraping practices
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 **Future Enhancements**
|
||||||
|
|
||||||
|
### **Phase 2 Features**
|
||||||
|
- **Historical Trends**: Visual charts showing song performance over time
|
||||||
|
- **Artist Analytics**: Artist-specific statistics and rankings
|
||||||
|
- **Genre Analysis**: Genre-based filtering and analysis
|
||||||
|
- **Advanced Filtering**: Date range, artist, song title filters
|
||||||
|
|
||||||
|
### **Phase 3 Features**
|
||||||
|
- **User Accounts**: Save favorite charts and analyses
|
||||||
|
- **Social Features**: Share charts and insights
|
||||||
|
- **API Access**: Public API for developers
|
||||||
|
- **Data Visualization**: Interactive charts and graphs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 **Implementation Checklist**
|
||||||
|
|
||||||
|
### **Backend Setup**
|
||||||
|
- [ ] Choose server framework and language
|
||||||
|
- [ ] Set up web scraping library
|
||||||
|
- [ ] Implement API endpoints
|
||||||
|
- [ ] Create data models
|
||||||
|
- [ ] Implement yearly ranking algorithm
|
||||||
|
- [ ] Add error handling and logging
|
||||||
|
- [ ] Set up testing framework
|
||||||
|
|
||||||
|
### **Frontend Setup**
|
||||||
|
- [ ] Choose UI framework
|
||||||
|
- [ ] Set up state management
|
||||||
|
- [ ] Create reusable components
|
||||||
|
- [ ] Implement API integration
|
||||||
|
- [ ] Add download functionality
|
||||||
|
- [ ] Implement responsive design
|
||||||
|
- [ ] Add loading and error states
|
||||||
|
|
||||||
|
### **Mobile Setup (if applicable)**
|
||||||
|
- [ ] Set up mobile development environment
|
||||||
|
- [ ] Create data models
|
||||||
|
- [ ] Implement network layer
|
||||||
|
- [ ] Create UI components
|
||||||
|
- [ ] Add native features (download, share)
|
||||||
|
- [ ] Test on devices
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 **Current Implementation Details**
|
||||||
|
|
||||||
|
### **Tech Stack**
|
||||||
|
- **Backend**: Node.js, Express.js, Cheerio, Axios
|
||||||
|
- **Frontend**: React 18, Custom Hooks, CSS3
|
||||||
|
- **Development**: npm, nodemon
|
||||||
|
|
||||||
|
### **Project Structure**
|
||||||
|
```
|
||||||
|
m/
|
||||||
|
├── frontend/ # React frontend application
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── components/ # React components
|
||||||
|
│ │ ├── hooks/ # Custom React hooks
|
||||||
|
│ │ ├── services/ # API service layer
|
||||||
|
│ │ ├── App.jsx # Main app component
|
||||||
|
│ │ ├── index.js # Entry point
|
||||||
|
│ │ └── styles.css # Global styles
|
||||||
|
│ ├── public/
|
||||||
|
│ └── package.json
|
||||||
|
└── backend/ # Node.js/Express backend API
|
||||||
|
├── src/
|
||||||
|
│ ├── routes/ # API routes
|
||||||
|
│ ├── controllers/ # Route controllers
|
||||||
|
│ ├── models/ # Data models & scraping logic
|
||||||
|
│ ├── middleware/ # Express middleware
|
||||||
|
│ └── server.js # Main server file
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Key Files**
|
||||||
|
- `start.sh` - Startup script for both services
|
||||||
|
- `README.md` - Setup and usage instructions
|
||||||
|
- `PRD.md` - This product requirements document
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
This PRD provides a comprehensive blueprint for recreating the Music Charts Archive Scraper in any technology stack, from web frameworks to mobile platforms. The modular architecture and clear data flow make it adaptable to different implementation approaches while maintaining the core functionality and user experience.
|
||||||
47812
docs/charts/herse-songList-export.json
Normal file
47812
docs/charts/herse-songList-export.json
Normal file
File diff suppressed because it is too large
Load Diff
48832
docs/herse-songList-export.json
Normal file
48832
docs/herse-songList-export.json
Normal file
File diff suppressed because it is too large
Load Diff
51832
docs/songList.json
Normal file
51832
docs/songList.json
Normal file
File diff suppressed because it is too large
Load Diff
20271
frontend/package-lock.json
generated
Normal file
20271
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
frontend/package.json
Normal file
36
frontend/package.json
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "chart-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "React frontend for chart data visualization",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "react-scripts start",
|
||||||
|
"build": "react-scripts build",
|
||||||
|
"test": "react-scripts test",
|
||||||
|
"eject": "react-scripts eject"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-scripts": "5.0.1",
|
||||||
|
"axios": "^1.6.0",
|
||||||
|
"recharts": "^2.8.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@testing-library/jest-dom": "^5.16.4",
|
||||||
|
"@testing-library/react": "^13.3.0",
|
||||||
|
"@testing-library/user-event": "^13.5.0"
|
||||||
|
},
|
||||||
|
"browserslist": {
|
||||||
|
"production": [
|
||||||
|
">0.2%",
|
||||||
|
"not dead",
|
||||||
|
"not op_mini all"
|
||||||
|
],
|
||||||
|
"development": [
|
||||||
|
"last 1 chrome version",
|
||||||
|
"last 1 firefox version",
|
||||||
|
"last 1 safari version"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
18
frontend/public/index.html
Normal file
18
frontend/public/index.html
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="theme-color" content="#000000" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Chart Data Visualization Application"
|
||||||
|
/>
|
||||||
|
<title>Chart Data Visualization</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
118
frontend/src/App.jsx
Normal file
118
frontend/src/App.jsx
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import Layout from './components/Layout';
|
||||||
|
import YearSelector from './components/YearSelector';
|
||||||
|
import DateList from './components/DateList';
|
||||||
|
import ChartTable from './components/ChartTable';
|
||||||
|
import DownloadButton from './components/DownloadButton';
|
||||||
|
import YearlyTopSongs from './components/YearlyTopSongs';
|
||||||
|
import YearlyDownloadButton from './components/YearlyDownloadButton';
|
||||||
|
import { useChartDates } from './hooks/useChartDates';
|
||||||
|
import { useChartData } from './hooks/useChartData';
|
||||||
|
import { useYearlyTopSongs } from './hooks/useYearlyTopSongs';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [selectedYear, setSelectedYear] = useState(new Date().getFullYear());
|
||||||
|
const [selectedDate, setSelectedDate] = useState(null);
|
||||||
|
const [viewMode, setViewMode] = useState('weekly'); // 'weekly' or 'yearly'
|
||||||
|
|
||||||
|
const { dates, loading: datesLoading, error: datesError } = useChartDates(selectedYear);
|
||||||
|
const { chartData, loading: dataLoading, error: dataError } = useChartData(selectedDate);
|
||||||
|
const { yearlySongs, loading: yearlyLoading, error: yearlyError } = useYearlyTopSongs(viewMode === 'yearly' ? selectedYear : null);
|
||||||
|
|
||||||
|
const handleYearChange = (year) => {
|
||||||
|
setSelectedYear(year);
|
||||||
|
setSelectedDate(null); // Reset selected date when year changes
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDateSelect = (date) => {
|
||||||
|
setSelectedDate(date);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewModeChange = (mode) => {
|
||||||
|
setViewMode(mode);
|
||||||
|
if (mode === 'yearly') {
|
||||||
|
setSelectedDate(null); // Clear selected date when switching to yearly view
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="app">
|
||||||
|
<header className="app-header">
|
||||||
|
<h1>Music Charts Archive</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="app-main">
|
||||||
|
<div className="controls-section">
|
||||||
|
<div className="year-selector-container">
|
||||||
|
<YearSelector
|
||||||
|
selectedYear={selectedYear}
|
||||||
|
onYearChange={handleYearChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="view-mode-selector">
|
||||||
|
<label>View Mode:</label>
|
||||||
|
<div className="view-mode-buttons">
|
||||||
|
<button
|
||||||
|
className={`view-mode-btn ${viewMode === 'weekly' ? 'active' : ''}`}
|
||||||
|
onClick={() => handleViewModeChange('weekly')}
|
||||||
|
>
|
||||||
|
Weekly Charts
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`view-mode-btn ${viewMode === 'yearly' ? 'active' : ''}`}
|
||||||
|
onClick={() => handleViewModeChange('yearly')}
|
||||||
|
>
|
||||||
|
Yearly Top Songs
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{viewMode === 'weekly' && (
|
||||||
|
<DateList
|
||||||
|
dates={dates}
|
||||||
|
selectedDate={selectedDate}
|
||||||
|
onDateSelect={handleDateSelect}
|
||||||
|
loading={datesLoading}
|
||||||
|
error={datesError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="chart-section">
|
||||||
|
{viewMode === 'weekly' ? (
|
||||||
|
<>
|
||||||
|
<ChartTable
|
||||||
|
data={chartData}
|
||||||
|
loading={dataLoading}
|
||||||
|
error={dataError}
|
||||||
|
selectedDate={selectedDate}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{chartData && chartData.length > 0 && (
|
||||||
|
<DownloadButton data={chartData} selectedDate={selectedDate} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<YearlyTopSongs
|
||||||
|
data={yearlySongs}
|
||||||
|
loading={yearlyLoading}
|
||||||
|
error={yearlyError}
|
||||||
|
year={selectedYear}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{yearlySongs && yearlySongs.length > 0 && (
|
||||||
|
<YearlyDownloadButton data={yearlySongs} year={selectedYear} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
63
frontend/src/components/ChartTable.jsx
Normal file
63
frontend/src/components/ChartTable.jsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const ChartTable = ({ data, loading, error, selectedDate }) => {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="chart-table">
|
||||||
|
<h3>Music Chart Data</h3>
|
||||||
|
<div className="loading">Loading chart data...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="chart-table">
|
||||||
|
<h3>Music Chart Data</h3>
|
||||||
|
<div className="error">Error loading chart data: {error}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="chart-table">
|
||||||
|
<h3>Music Chart Data</h3>
|
||||||
|
<div className="no-data">
|
||||||
|
{selectedDate
|
||||||
|
? `No chart data available for ${selectedDate}. Please select a different date.`
|
||||||
|
: 'No data available. Please select a chart date.'
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chart-table">
|
||||||
|
<h3>Music Chart Data {selectedDate && `- ${selectedDate}`}</h3>
|
||||||
|
<div className="table-container">
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Rank</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Artist</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.map((song, index) => (
|
||||||
|
<tr key={index}>
|
||||||
|
<td className="rank-cell">{song.order}</td>
|
||||||
|
<td className="title-cell">{song.title}</td>
|
||||||
|
<td className="artist-cell">{song.artist}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChartTable;
|
||||||
49
frontend/src/components/DateList.jsx
Normal file
49
frontend/src/components/DateList.jsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const DateList = ({ dates, selectedDate, onDateSelect, loading, error }) => {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="date-list">
|
||||||
|
<h3>Available Chart Dates</h3>
|
||||||
|
<div className="loading">Loading chart dates...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="date-list">
|
||||||
|
<h3>Available Chart Dates</h3>
|
||||||
|
<div className="error">Error loading dates: {error}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dates || dates.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="date-list">
|
||||||
|
<h3>Available Chart Dates</h3>
|
||||||
|
<div className="no-dates">No chart dates available for selected year</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="date-list">
|
||||||
|
<h3>Available Chart Dates</h3>
|
||||||
|
<div className="date-grid">
|
||||||
|
{dates.map((dateObj) => (
|
||||||
|
<button
|
||||||
|
key={dateObj.date}
|
||||||
|
className={`date-item ${selectedDate === dateObj.date ? 'selected' : ''}`}
|
||||||
|
onClick={() => onDateSelect(dateObj.date)}
|
||||||
|
>
|
||||||
|
{dateObj.formattedDate}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DateList;
|
||||||
55
frontend/src/components/DownloadButton.jsx
Normal file
55
frontend/src/components/DownloadButton.jsx
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const DownloadButton = ({ data, selectedDate }) => {
|
||||||
|
const downloadJSON = () => {
|
||||||
|
if (!data || data.length === 0) return;
|
||||||
|
|
||||||
|
// Format the date for the title
|
||||||
|
const formatDate = (dateString) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create JSON content for music chart data
|
||||||
|
const jsonData = {
|
||||||
|
title: `${formatDate(selectedDate)} - Top Songs`,
|
||||||
|
date: selectedDate,
|
||||||
|
totalSongs: data.length,
|
||||||
|
songs: data
|
||||||
|
};
|
||||||
|
|
||||||
|
const jsonContent = JSON.stringify(jsonData, null, 2);
|
||||||
|
|
||||||
|
// Create and download the file
|
||||||
|
const blob = new Blob([jsonContent], { type: 'application/json;charset=utf-8;' });
|
||||||
|
const link = document.createElement('a');
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
const dateStr = selectedDate ? selectedDate : 'chart-data';
|
||||||
|
link.setAttribute('href', url);
|
||||||
|
link.setAttribute('download', `music-chart-${dateStr}.json`);
|
||||||
|
link.style.visibility = 'hidden';
|
||||||
|
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="download-section">
|
||||||
|
<button
|
||||||
|
onClick={downloadJSON}
|
||||||
|
className="download-button"
|
||||||
|
disabled={!data || data.length === 0}
|
||||||
|
>
|
||||||
|
Download Chart as JSON
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DownloadButton;
|
||||||
128
frontend/src/components/JsonViewer.jsx
Normal file
128
frontend/src/components/JsonViewer.jsx
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
const JsonViewer = ({ data, title, filename, year }) => {
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
const [selectedProperties, setSelectedProperties] = useState({
|
||||||
|
position: true,
|
||||||
|
title: true,
|
||||||
|
artist: true,
|
||||||
|
totalPoints: false,
|
||||||
|
highestPosition: false,
|
||||||
|
appearances: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter data based on selected properties and format to match songList.json structure
|
||||||
|
const formatData = () => {
|
||||||
|
if (!data || data.length === 0) return null;
|
||||||
|
|
||||||
|
const filteredSongs = data.map(item => {
|
||||||
|
const filteredSong = {};
|
||||||
|
|
||||||
|
// Map order to position to match the expected format
|
||||||
|
if (selectedProperties.position && item.hasOwnProperty('order')) {
|
||||||
|
filteredSong.position = item.order;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add other selected properties
|
||||||
|
Object.keys(selectedProperties).forEach(prop => {
|
||||||
|
if (prop !== 'position' && selectedProperties[prop] && item.hasOwnProperty(prop)) {
|
||||||
|
filteredSong[prop] = item[prop];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return filteredSong;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `${year} - Top 50 Songs`,
|
||||||
|
songs: filteredSongs
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const formattedData = formatData();
|
||||||
|
const jsonString = formattedData ? JSON.stringify(formattedData, null, 2) : '[]';
|
||||||
|
|
||||||
|
const copyToClipboard = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(jsonString);
|
||||||
|
alert('JSON copied to clipboard!');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy: ', err);
|
||||||
|
// Fallback for older browsers
|
||||||
|
const textArea = document.createElement('textarea');
|
||||||
|
textArea.value = jsonString;
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
alert('JSON copied to clipboard!');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadJSON = () => {
|
||||||
|
const blob = new Blob([jsonString], { type: 'application/json;charset=utf-8;' });
|
||||||
|
const link = document.createElement('a');
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
link.setAttribute('href', url);
|
||||||
|
link.setAttribute('download', filename);
|
||||||
|
link.style.visibility = 'hidden';
|
||||||
|
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="json-viewer">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsVisible(!isVisible)}
|
||||||
|
className="json-toggle-btn"
|
||||||
|
>
|
||||||
|
{isVisible ? 'Hide' : 'Show'} JSON Data
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isVisible && (
|
||||||
|
<div className="json-container">
|
||||||
|
<div className="json-header">
|
||||||
|
<h4>{title}</h4>
|
||||||
|
<div className="json-actions">
|
||||||
|
<button onClick={copyToClipboard} className="copy-btn">
|
||||||
|
Copy JSON
|
||||||
|
</button>
|
||||||
|
<button onClick={downloadJSON} className="download-btn">
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Property Selection */}
|
||||||
|
<div className="property-selector">
|
||||||
|
<h5>Select Properties to Include:</h5>
|
||||||
|
<div className="property-checkboxes">
|
||||||
|
{Object.keys(selectedProperties).map(prop => (
|
||||||
|
<label key={prop} className="property-checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedProperties[prop]}
|
||||||
|
onChange={(e) => setSelectedProperties(prev => ({
|
||||||
|
...prev,
|
||||||
|
[prop]: e.target.checked
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<span className="property-label">
|
||||||
|
{prop === 'position' ? 'position (rank)' : prop}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<pre className="json-content">{jsonString}</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default JsonViewer;
|
||||||
13
frontend/src/components/Layout.jsx
Normal file
13
frontend/src/components/Layout.jsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const Layout = ({ children }) => {
|
||||||
|
return (
|
||||||
|
<div className="layout">
|
||||||
|
<div className="layout-container">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
26
frontend/src/components/YearSelector.jsx
Normal file
26
frontend/src/components/YearSelector.jsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const YearSelector = ({ selectedYear, onYearChange }) => {
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const years = Array.from({ length: 10 }, (_, i) => currentYear - i);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="year-selector">
|
||||||
|
<label htmlFor="year-select">Select Year:</label>
|
||||||
|
<select
|
||||||
|
id="year-select"
|
||||||
|
value={selectedYear}
|
||||||
|
onChange={(e) => onYearChange(parseInt(e.target.value))}
|
||||||
|
className="year-select"
|
||||||
|
>
|
||||||
|
{years.map(year => (
|
||||||
|
<option key={year} value={year}>
|
||||||
|
{year}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default YearSelector;
|
||||||
50
frontend/src/components/YearlyDownloadButton.jsx
Normal file
50
frontend/src/components/YearlyDownloadButton.jsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const YearlyDownloadButton = ({ data, year }) => {
|
||||||
|
const downloadJSON = () => {
|
||||||
|
if (!data || data.length === 0) return;
|
||||||
|
|
||||||
|
// Create JSON content for yearly top songs data
|
||||||
|
// Filter out calculation properties to keep original download format
|
||||||
|
const songsForDownload = data.map(song => ({
|
||||||
|
order: song.order,
|
||||||
|
title: song.title,
|
||||||
|
artist: song.artist
|
||||||
|
}));
|
||||||
|
|
||||||
|
const jsonData = {
|
||||||
|
year: year,
|
||||||
|
title: `Top Songs of ${year}`,
|
||||||
|
songs: songsForDownload
|
||||||
|
};
|
||||||
|
|
||||||
|
const jsonContent = JSON.stringify(jsonData, null, 2);
|
||||||
|
|
||||||
|
// Create and download the file
|
||||||
|
const blob = new Blob([jsonContent], { type: 'application/json;charset=utf-8;' });
|
||||||
|
const link = document.createElement('a');
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
link.setAttribute('href', url);
|
||||||
|
link.setAttribute('download', `yearly-top-songs-${year}.json`);
|
||||||
|
link.style.visibility = 'hidden';
|
||||||
|
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="download-section">
|
||||||
|
<button
|
||||||
|
onClick={downloadJSON}
|
||||||
|
className="download-button"
|
||||||
|
disabled={!data || data.length === 0}
|
||||||
|
>
|
||||||
|
Download Yearly Top Songs as JSON
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default YearlyDownloadButton;
|
||||||
83
frontend/src/components/YearlyTopSongs.jsx
Normal file
83
frontend/src/components/YearlyTopSongs.jsx
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import JsonViewer from './JsonViewer';
|
||||||
|
|
||||||
|
const YearlyTopSongs = ({ data, loading, error, year }) => {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="chart-table">
|
||||||
|
<h3>Yearly Top Songs</h3>
|
||||||
|
<div className="loading">
|
||||||
|
<div>Calculating yearly top songs...</div>
|
||||||
|
<div style={{ fontSize: '12px', marginTop: '8px', color: '#999' }}>
|
||||||
|
This may take a few minutes as we analyze all chart data for {year}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="chart-table">
|
||||||
|
<h3>Yearly Top Songs</h3>
|
||||||
|
<div className="error">Error calculating yearly top songs: {error}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="chart-table">
|
||||||
|
<h3>Yearly Top Songs</h3>
|
||||||
|
<div className="no-data">
|
||||||
|
{year
|
||||||
|
? `No yearly data available for ${year}. Please select a different year.`
|
||||||
|
: 'No yearly data available. Please select a year.'
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chart-table">
|
||||||
|
<h3>Top Songs of {year}</h3>
|
||||||
|
<div className="table-container">
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Rank</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Artist</th>
|
||||||
|
<th>Total Points</th>
|
||||||
|
<th>Best Position</th>
|
||||||
|
<th>Weeks on Chart</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.map((song, index) => (
|
||||||
|
<tr key={index}>
|
||||||
|
<td className="rank-cell">{song.order}</td>
|
||||||
|
<td className="title-cell">{song.title}</td>
|
||||||
|
<td className="artist-cell">{song.artist}</td>
|
||||||
|
<td className="points-cell">{song.totalPoints}</td>
|
||||||
|
<td className="position-cell">#{song.highestPosition}</td>
|
||||||
|
<td className="appearances-cell">{song.appearances}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* JSON Viewer for debugging and data inspection */}
|
||||||
|
<JsonViewer
|
||||||
|
data={data}
|
||||||
|
title={`Raw JSON Data for ${year} Yearly Top Songs`}
|
||||||
|
filename={`yearly-top-songs-${year}-raw.json`}
|
||||||
|
year={year}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default YearlyTopSongs;
|
||||||
35
frontend/src/hooks/useChartData.js
Normal file
35
frontend/src/hooks/useChartData.js
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { getChartData } from '../services/chartApi';
|
||||||
|
|
||||||
|
export const useChartData = (selectedDate) => {
|
||||||
|
const [chartData, setChartData] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchChartData = async () => {
|
||||||
|
if (!selectedDate) {
|
||||||
|
setChartData([]);
|
||||||
|
setError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await getChartData(selectedDate);
|
||||||
|
setChartData(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Failed to fetch chart data');
|
||||||
|
setChartData([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchChartData();
|
||||||
|
}, [selectedDate]);
|
||||||
|
|
||||||
|
return { chartData, loading, error };
|
||||||
|
};
|
||||||
31
frontend/src/hooks/useChartDates.js
Normal file
31
frontend/src/hooks/useChartDates.js
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { getChartDates } from '../services/chartApi';
|
||||||
|
|
||||||
|
export const useChartDates = (year) => {
|
||||||
|
const [dates, setDates] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchDates = async () => {
|
||||||
|
if (!year) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const datesData = await getChartDates(year);
|
||||||
|
setDates(datesData);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Failed to fetch dates');
|
||||||
|
setDates([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchDates();
|
||||||
|
}, [year]);
|
||||||
|
|
||||||
|
return { dates, loading, error };
|
||||||
|
};
|
||||||
35
frontend/src/hooks/useYearlyTopSongs.js
Normal file
35
frontend/src/hooks/useYearlyTopSongs.js
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { getYearlyTopSongs } from '../services/chartApi';
|
||||||
|
|
||||||
|
export const useYearlyTopSongs = (year) => {
|
||||||
|
const [yearlySongs, setYearlySongs] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchYearlyTopSongs = async () => {
|
||||||
|
if (!year) {
|
||||||
|
setYearlySongs([]);
|
||||||
|
setError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await getYearlyTopSongs(year);
|
||||||
|
setYearlySongs(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Failed to fetch yearly top songs');
|
||||||
|
setYearlySongs([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchYearlyTopSongs();
|
||||||
|
}, [year]);
|
||||||
|
|
||||||
|
return { yearlySongs, loading, error };
|
||||||
|
};
|
||||||
11
frontend/src/index.js
Normal file
11
frontend/src/index.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import './styles.css';
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||||
|
root.render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
48
frontend/src/services/chartApi.js
Normal file
48
frontend/src/services/chartApi.js
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
// Configure base URL for API calls
|
||||||
|
const API_BASE_URL = process.env.REACT_APP_API_BASE_URL || 'http://localhost:3001/api';
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
timeout: 300000, // 5 minutes for yearly calculations
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get available dates for a specific year
|
||||||
|
export const getChartDates = async (year) => {
|
||||||
|
try {
|
||||||
|
const response = await api.get(`/chart/dates/${year}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching chart dates:', error);
|
||||||
|
throw new Error(error.response?.data?.message || 'Failed to fetch chart dates');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get chart data for a specific date
|
||||||
|
export const getChartData = async (date) => {
|
||||||
|
try {
|
||||||
|
const response = await api.get(`/chart/data/${date}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching chart data:', error);
|
||||||
|
throw new Error(error.response?.data?.message || 'Failed to fetch chart data');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get yearly top songs
|
||||||
|
export const getYearlyTopSongs = async (year) => {
|
||||||
|
try {
|
||||||
|
const response = await api.get(`/chart/yearly-top/${year}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching yearly top songs:', error);
|
||||||
|
throw new Error(error.response?.data?.message || 'Failed to fetch yearly top songs');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Export the api instance for potential future use
|
||||||
|
export default api;
|
||||||
492
frontend/src/styles.css
Normal file
492
frontend/src/styles.css
Normal file
@ -0,0 +1,492 @@
|
|||||||
|
/* Reset and base styles */
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||||
|
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||||
|
sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Layout styles */
|
||||||
|
.layout {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* App styles */
|
||||||
|
.app {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 30px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 300;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
padding: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Controls section */
|
||||||
|
.controls-section {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 2fr;
|
||||||
|
gap: 30px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Year selector and view mode */
|
||||||
|
.year-selector-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.year-selector {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.year-selector label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
.year-select {
|
||||||
|
padding: 12px;
|
||||||
|
border: 2px solid #e9ecef;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 16px;
|
||||||
|
background: white;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.year-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* View mode selector */
|
||||||
|
.view-mode-selector {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-mode-selector label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-mode-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-mode-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border: 2px solid #e9ecef;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: white;
|
||||||
|
color: #495057;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-mode-btn:hover {
|
||||||
|
border-color: #667eea;
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-mode-btn.active {
|
||||||
|
background: #667eea;
|
||||||
|
border-color: #667eea;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Date list */
|
||||||
|
.date-list h3 {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
color: #495057;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-item {
|
||||||
|
padding: 10px;
|
||||||
|
background: white;
|
||||||
|
border: 2px solid #e9ecef;
|
||||||
|
border-radius: 6px;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-item:hover {
|
||||||
|
border-color: #667eea;
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-item.selected {
|
||||||
|
background: #667eea;
|
||||||
|
border-color: #667eea;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chart section */
|
||||||
|
.chart-section {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-table h3 {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 0;
|
||||||
|
border-bottom: 1px solid #dee2e6;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th,
|
||||||
|
.data-table td {
|
||||||
|
padding: 12px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th {
|
||||||
|
background: #f8f9fa;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #495057;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tr:hover {
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-cell {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #333;
|
||||||
|
width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-cell {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-cell {
|
||||||
|
color: #666;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.points-cell {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #28a745;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-cell {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #007bff;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.appearances-cell {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #6f42c1;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tr:nth-child(1) .rank-cell {
|
||||||
|
color: #ffd700;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tr:nth-child(2) .rank-cell {
|
||||||
|
color: #c0c0c0;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tr:nth-child(3) .rank-cell {
|
||||||
|
color: #cd7f32;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Download section */
|
||||||
|
.download-section {
|
||||||
|
margin-top: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-button {
|
||||||
|
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
box-shadow: 0 4px 15px rgba(40, 167, 69, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-button:hover:not(:disabled) {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 20px rgba(40, 167, 69, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-button:disabled {
|
||||||
|
background: #6c757d;
|
||||||
|
cursor: not-allowed;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading and error states */
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
color: #6c757d;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
color: #dc3545;
|
||||||
|
background: #f8d7da;
|
||||||
|
border: 1px solid #f5c6cb;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-data,
|
||||||
|
.no-dates {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
color: #6c757d;
|
||||||
|
font-style: italic;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* JSON Viewer styles */
|
||||||
|
.json-viewer {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-toggle-btn {
|
||||||
|
background: linear-gradient(135deg, #6c757d 0%, #495057 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-toggle-btn:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(108, 117, 125, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-container {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-header {
|
||||||
|
background: #e9ecef;
|
||||||
|
padding: 15px 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-header h4 {
|
||||||
|
margin: 0;
|
||||||
|
color: #495057;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.property-selector {
|
||||||
|
background: #f1f3f4;
|
||||||
|
padding: 15px 20px;
|
||||||
|
border-bottom: 1px solid #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.property-selector h5 {
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
color: #495057;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.property-checkboxes {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.property-checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
.property-checkbox input[type="checkbox"] {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.property-label {
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn, .download-btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn {
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn:hover {
|
||||||
|
background: #0056b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-btn {
|
||||||
|
background: #28a745;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-btn:hover {
|
||||||
|
background: #1e7e34;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-content {
|
||||||
|
background: #2d3748;
|
||||||
|
color: #e2e8f0;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive design */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.layout-container {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-section {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th,
|
||||||
|
.data-table td {
|
||||||
|
padding: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
54
start.sh
Normal file
54
start.sh
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo "🎵 Starting Music Charts Archive Scraper..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check if Node.js is installed
|
||||||
|
if ! command -v node &> /dev/null; then
|
||||||
|
echo "❌ Node.js is not installed. Please install Node.js first."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if npm is installed
|
||||||
|
if ! command -v npm &> /dev/null; then
|
||||||
|
echo "❌ npm is not installed. Please install npm first."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "📦 Installing backend dependencies..."
|
||||||
|
cd backend
|
||||||
|
npm install
|
||||||
|
|
||||||
|
echo "📦 Installing frontend dependencies..."
|
||||||
|
cd ../frontend
|
||||||
|
npm install
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "🚀 Starting services..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Start backend in background
|
||||||
|
echo "🔧 Starting backend server on http://localhost:3001"
|
||||||
|
cd ../backend
|
||||||
|
npm run dev &
|
||||||
|
BACKEND_PID=$!
|
||||||
|
|
||||||
|
# Wait a moment for backend to start
|
||||||
|
sleep 3
|
||||||
|
|
||||||
|
# Start frontend
|
||||||
|
echo "🎨 Starting frontend server on http://localhost:3000"
|
||||||
|
cd ../frontend
|
||||||
|
npm start &
|
||||||
|
FRONTEND_PID=$!
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ Services started successfully!"
|
||||||
|
echo "📊 Frontend: http://localhost:3000"
|
||||||
|
echo "🔌 Backend: http://localhost:3001"
|
||||||
|
echo "🏥 Health check: http://localhost:3001/api/health"
|
||||||
|
echo ""
|
||||||
|
echo "Press Ctrl+C to stop all services"
|
||||||
|
|
||||||
|
# Wait for user to stop
|
||||||
|
wait
|
||||||
Loading…
Reference in New Issue
Block a user