added inintla stuff
This commit is contained in:
48
.gitignore
vendored
Normal file
48
.gitignore
vendored
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# Node modules
|
||||||
|
node_modules/
|
||||||
|
**/node_modules/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# dotenv files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
**/dist/
|
||||||
|
**/build/
|
||||||
|
|
||||||
|
# Vite cache
|
||||||
|
.vite/
|
||||||
|
.vitepress/cache/
|
||||||
|
|
||||||
|
# MacOS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# VS Code
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
*.tmp
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# npm package lock (optional, usually you commit this)
|
||||||
|
# package-lock.json
|
||||||
|
# yarn.lock
|
||||||
|
|
||||||
|
# Coverage (if you run tests)
|
||||||
|
coverage/
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
1208
backend/package-lock.json
generated
Normal file
1208
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
14
backend/package.json
Normal file
14
backend/package.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "music-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"dev": "nodemon server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"music-metadata": "^8.0.0",
|
||||||
|
"cors": "^2.8.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
105
backend/server.js
Normal file
105
backend/server.js
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
// server.js
|
||||||
|
const express = require('express');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs/promises');
|
||||||
|
const mm = require('music-metadata');
|
||||||
|
const cors = require('cors');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(cors()); // adjust for production if needed
|
||||||
|
|
||||||
|
// Base music directory
|
||||||
|
const MUSIC_DIR = path.resolve(__dirname, 'music');
|
||||||
|
const SUPPORTED = ['.flac', '.mp3', '.wav', '.m4a', '.ogg'];
|
||||||
|
|
||||||
|
// Serve static music files
|
||||||
|
app.use('/music', express.static(MUSIC_DIR, {
|
||||||
|
setHeaders: (res) => {
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Recursive scan for all music files
|
||||||
|
async function scanDir(dir) {
|
||||||
|
let results = [];
|
||||||
|
const files = await fs.readdir(dir, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const fullPath = path.join(dir, file.name);
|
||||||
|
if (file.isDirectory()) {
|
||||||
|
results = results.concat(await scanDir(fullPath));
|
||||||
|
} else if (SUPPORTED.includes(path.extname(file.name).toLowerCase())) {
|
||||||
|
results.push(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse filename into metadata
|
||||||
|
function parseFilename(filename) {
|
||||||
|
// Expected: Title__Movie__Genre__YYYY-MM-DD.ext
|
||||||
|
const base = path.basename(filename);
|
||||||
|
const noExt = base.replace(/\.[^.]+$/, '');
|
||||||
|
const parts = noExt.split('__');
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: parts[0] || 'Unknown',
|
||||||
|
movie: parts[1] && parts[1] !== 'None' ? parts[1] : null,
|
||||||
|
genre: parts[2] || null,
|
||||||
|
date: parts[3] || null,
|
||||||
|
filename: base,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// API endpoint: list all tracks
|
||||||
|
app.get('/api/tracks', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const files = await scanDir(MUSIC_DIR);
|
||||||
|
const tracks = [];
|
||||||
|
|
||||||
|
for (const f of files) {
|
||||||
|
const stat = await fs.stat(f);
|
||||||
|
const metadata = parseFilename(f);
|
||||||
|
|
||||||
|
metadata.path = `/music/${encodeURIComponent(path.relative(MUSIC_DIR, f))}`;
|
||||||
|
metadata.size = stat.size;
|
||||||
|
metadata.ext = path.extname(f).toLowerCase();
|
||||||
|
metadata.duration = null;
|
||||||
|
metadata.tags = {};
|
||||||
|
|
||||||
|
// Try reading embedded metadata (optional)
|
||||||
|
try {
|
||||||
|
const mmData = await mm.parseFile(f, { duration: true });
|
||||||
|
if (mmData.format.duration) metadata.duration = Math.round(mmData.format.duration);
|
||||||
|
if (mmData.common) {
|
||||||
|
metadata.tags = mmData.common;
|
||||||
|
metadata.title = mmData.common.title || metadata.title;
|
||||||
|
metadata.movie = mmData.common.album || metadata.movie;
|
||||||
|
if (!metadata.genre && mmData.common.genre) {
|
||||||
|
metadata.genre = Array.isArray(mmData.common.genre) ? mmData.common.genre.join(', ') : mmData.common.genre;
|
||||||
|
}
|
||||||
|
if (!metadata.date && mmData.common.date) metadata.date = mmData.common.date;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// ignore metadata errors
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks.push(metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort: first by date (desc), then by filename
|
||||||
|
tracks.sort((a, b) => {
|
||||||
|
if (a.date && b.date) return String(b.date).localeCompare(String(a.date));
|
||||||
|
return a.filename.localeCompare(b.filename);
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ tracks });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Failed to read music folder' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
const PORT = process.env.PORT || 3001;
|
||||||
|
app.listen(PORT, () => console.log(`Music API server running at http://localhost:${PORT}`));
|
||||||
14
frontend/index.html
Normal file
14
frontend/index.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>Keshav Music</title>
|
||||||
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/flac.js@latest/dist/flac.min.js"></script>
|
||||||
|
</html>
|
||||||
1909
frontend/package-lock.json
generated
Normal file
1909
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
frontend/package.json
Normal file
19
frontend/package.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "music-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview --port 5174"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.13.2",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"vite": "^5.0.0",
|
||||||
|
"@vitejs/plugin-react": "^4.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
118
frontend/src/app.jsx
Normal file
118
frontend/src/app.jsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import axios from "axios";
|
||||||
|
import "../src/styles.css";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [allTracks, setAllTracks] = useState([]);
|
||||||
|
const [sortOption, setSortOption] = useState("recent"); // default sort
|
||||||
|
|
||||||
|
// Hard-coded featured tracks (filenames only)
|
||||||
|
const FEATURED = [
|
||||||
|
"Kanne Kalimaane__Moondram Pirai__Unplucked Instrumental Studio Music Cover__2025-06-26.flac",
|
||||||
|
"Pudhu Vellai Mazhai__Roja__Unplucked Instrumental Studio Music Cover__2025-03-15.flac",
|
||||||
|
"Porkalam and Kannana Kanney Medley__Thenali and Viswasam__Unplucked Instrumental Studio Music Cover__2025-01-01.flac"
|
||||||
|
];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
axios.get("http://localhost:3001/api/tracks").then(res => {
|
||||||
|
setAllTracks(res.data.tracks);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Parse filename into metadata
|
||||||
|
const parseTrack = (track) => {
|
||||||
|
const nameNoExt = track.filename.replace(/\.[^.]+$/, "");
|
||||||
|
const parts = nameNoExt.split("__");
|
||||||
|
return {
|
||||||
|
title: parts[0] || "Unknown Title",
|
||||||
|
movie: parts[1] && parts[1] !== "None" ? parts[1] : null,
|
||||||
|
genre: parts[2] || null,
|
||||||
|
date: parts[3] || null,
|
||||||
|
url: "http://localhost:3001" + track.path,
|
||||||
|
ext: track.ext
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Separate featured and normal tracks
|
||||||
|
const featuredTracks = allTracks
|
||||||
|
.filter(t => FEATURED.includes(t.filename))
|
||||||
|
.map(parseTrack);
|
||||||
|
|
||||||
|
let normalTracks = allTracks
|
||||||
|
.filter(t => !FEATURED.includes(t.filename))
|
||||||
|
.map(parseTrack);
|
||||||
|
|
||||||
|
// Apply sorting
|
||||||
|
normalTracks.sort((a, b) => {
|
||||||
|
if (sortOption === "recent") return (b.date || "").localeCompare(a.date || "");
|
||||||
|
if (sortOption === "oldest") return (a.date || "").localeCompare(b.date || "");
|
||||||
|
if (sortOption === "a-z") return (a.title || "").localeCompare(b.title || "");
|
||||||
|
if (sortOption === "z-a") return (b.title || "").localeCompare(a.title || "");
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const TrackCard = ({ t, featured }) => (
|
||||||
|
<div className={featured ? "track featured-track" : "track"}>
|
||||||
|
<h3>{t.movie ? `${t.title} (${t.movie})` : t.title}</h3>
|
||||||
|
<p>{[t.genre, t.desc, t.date].filter(Boolean).join(" | ")}</p>
|
||||||
|
<audio controls>
|
||||||
|
<source src={t.url} type={`audio/${t.ext.replace(".", "")}`} />
|
||||||
|
Your browser does not support this audio format.
|
||||||
|
</audio>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<header>
|
||||||
|
<h1>Keshav’s Music Portfolio</h1>
|
||||||
|
<p style={{ marginTop: "20px", marginBottom: "20px" }}>
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
{/* FEATURED TRACKS */}
|
||||||
|
{featuredTracks.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<h2>Featured</h2>
|
||||||
|
<div className="featured-container">
|
||||||
|
{featuredTracks.map(t => (
|
||||||
|
<TrackCard key={t.url} t={t} featured={true} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* SORT DROPDOWN */}
|
||||||
|
{normalTracks.length > 0 && (
|
||||||
|
<div className="sort-container">
|
||||||
|
<label htmlFor="sort-select">Sort by: </label>
|
||||||
|
<select
|
||||||
|
id="sort-select"
|
||||||
|
value={sortOption}
|
||||||
|
onChange={(e) => setSortOption(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="recent">Most Recent</option>
|
||||||
|
<option value="oldest">Oldest</option>
|
||||||
|
<option value="a-z">A → Z</option>
|
||||||
|
<option value="z-a">Z → A</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p style={{ marginTop: "20px", marginBottom: "20px" }}></p>
|
||||||
|
|
||||||
|
{/* NORMAL TRACKS */}
|
||||||
|
<section className="all-container">
|
||||||
|
<h2></h2>
|
||||||
|
<p></p>
|
||||||
|
<div className="track-list">
|
||||||
|
{normalTracks.map(t => (
|
||||||
|
<TrackCard key={t.url} t={t} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
frontend/src/main.jsx
Normal file
6
frontend/src/main.jsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import App from './app';
|
||||||
|
import './styles.css';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')).render(<App />);
|
||||||
219
frontend/src/styles.css
Normal file
219
frontend/src/styles.css
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
:root {
|
||||||
|
--base-bg: #1e1e2e; /* Dark background */
|
||||||
|
--surface: #313244; /* Card background */
|
||||||
|
--overlay: #45475a; /* Hover / dropdown */
|
||||||
|
--text: #cdd6f4; /* Main text */
|
||||||
|
--subtext: #bac2de; /* Secondary text */
|
||||||
|
--accent: #a6e3a1; /* Greenish accent */
|
||||||
|
--accent-dark: #4caf50; /* Hover accent */
|
||||||
|
--shadow-light: rgba(0,0,0,0.3);
|
||||||
|
--shadow-glow: rgba(166,227,161,0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Global ===== */
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
background: var(--base-bg);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 2rem;
|
||||||
|
transition: background 0.3s ease, color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Header ===== */
|
||||||
|
header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
color: var(--accent);
|
||||||
|
text-shadow: 0 0 8px var(--shadow-glow), 0 0 16px rgba(166,227,161,0.2);
|
||||||
|
animation: glow-pulse 3s infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Links / Buttons ===== */
|
||||||
|
.links {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.links .btn {
|
||||||
|
background-color: var(--accent);
|
||||||
|
color: var(--base-bg);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 2px 6px var(--shadow-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.links .btn:hover {
|
||||||
|
background-color: var(--accent-dark);
|
||||||
|
box-shadow: 0 4px 14px var(--shadow-glow), 0 0 8px var(--shadow-glow);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Featured Tracks ===== */
|
||||||
|
.featured-container {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-track {
|
||||||
|
flex: 1 1 calc(33% - 1rem);
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 12px var(--shadow-light), 0 0 6px var(--shadow-glow);
|
||||||
|
transition: transform 0.4s ease, box-shadow 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animated edge glow for featured */
|
||||||
|
.featured-track::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 2px;
|
||||||
|
background: linear-gradient(120deg, rgba(166,227,161,0.3), rgba(166,227,161,0.1), rgba(166,227,161,0.3));
|
||||||
|
background-size: 400% 400%;
|
||||||
|
filter: blur(6px);
|
||||||
|
opacity: 0.4;
|
||||||
|
z-index: 0;
|
||||||
|
animation: glow-sweep 8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-track:hover::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keep content above pseudo-element */
|
||||||
|
.featured-track > * {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover effects for featured */
|
||||||
|
.featured-track:hover {
|
||||||
|
transform: translateY(-6px);
|
||||||
|
box-shadow: 0 12px 24px var(--shadow-light), 0 0 18px var(--shadow-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-track h3 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-track p {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--subtext);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Normal Tracks ===== */
|
||||||
|
.track {
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
box-shadow: 0 3px 10px var(--shadow-light);
|
||||||
|
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 16px var(--shadow-light), 0 0 8px var(--shadow-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--accent);
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track p {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--subtext);
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Audio Player ===== */
|
||||||
|
audio {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
outline: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Sort Dropdown ===== */
|
||||||
|
#sort-select {
|
||||||
|
padding: 0.5rem 0.8rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: none;
|
||||||
|
background: var(--overlay);
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: bold;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sort-select:hover {
|
||||||
|
box-shadow: 0 4px 12px var(--shadow-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Animations ===== */
|
||||||
|
@keyframes glow-sweep {
|
||||||
|
0% { background-position: 0% 50%; }
|
||||||
|
50% { background-position: 100% 50%; }
|
||||||
|
100% { background-position: 0% 50%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes glow-pulse {
|
||||||
|
0% { text-shadow: 0 0 8px var(--shadow-glow); }
|
||||||
|
50% { text-shadow: 0 0 16px var(--shadow-glow); }
|
||||||
|
100% { text-shadow: 0 0 8px var(--shadow-glow); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Responsive ===== */
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.featured-track {
|
||||||
|
flex: 1 1 calc(50% - 1rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.featured-track {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
frontend/vite.config.js
Normal file
10
frontend/vite.config.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 3002, // choose your port
|
||||||
|
strictPort: true // fail if port is in use
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user