artist-dashboard/
├─ package.json
├─ server.js
├─ db.sqlite (created at runtime)
├─ uploads/ (audio files saved here)
├─ public/
│ ├─ index.html (artist dashboard)
│ ├─ admin.html (admin preview)
│ └─ app.js (frontend JS)
│ └─ styles.css
{
"name": "artist-dashboard",
"version": "1.0.0",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"better-sqlite3": "^8.0.0",
"cors": "^2.8.5",
"express": "^4.18.2",
"express-fileupload": "^1.4.0"
}
}
// server.js
const express = require('express');
const fileUpload = require('express-fileupload');
const Database = require('better-sqlite3');
const path = require('path');
const fs = require('fs');
const cors = require('cors');
const PORT = process.env.PORT || 3000;
const ADMIN_TOKEN = process.env.ADMIN_TOKEN || 'changeme_admin_token';
const app = express();
app.use(cors());
app.use(express.json());
app.use(fileUpload());
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
app.use(express.static(path.join(__dirname, 'public')));
// ensure uploads dir
if (!fs.existsSync(path.join(__dirname, 'uploads'))) {
fs.mkdirSync(path.join(__dirname, 'uploads'));
}
// initialize DB
const db = new Database(path.join(__dirname, 'db.sqlite'));
db.exec(`
CREATE TABLE IF NOT EXISTS artists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT
);
CREATE TABLE IF NOT EXISTS tracks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
artist_id INTEGER,
title TEXT,
filename TEXT,
uploaded_at DATETIME DEFAULT CURRENT_TIMESTAMP,
approved INTEGER DEFAULT 0,
plays INTEGER DEFAULT 0,
royalty_rate REAL DEFAULT 0.003, -- example cents per play or $ per play simplified
FOREIGN KEY(artist_id) REFERENCES artists(id)
);
CREATE TABLE IF NOT EXISTS withdrawals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
artist_id INTEGER,
amount REAL,
status TEXT DEFAULT 'pending',
requested_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(artist_id) REFERENCES artists(id)
);
`);
// helper queries
const insertArtist = db.prepare('INSERT INTO artists(name,email) VALUES(?,?)');
const getArtistById = db.prepare('SELECT * FROM artists WHERE id = ?');
const insertTrack = db.prepare('INSERT INTO tracks(artist_id,title,filename,royalty_rate) VALUES(?,?,?,?)');
const listTracks = db.prepare('SELECT * FROM tracks WHERE artist_id = ? ORDER BY uploaded_at DESC');
const getTrackById = db.prepare('SELECT * FROM tracks WHERE id = ?');
const updateTrackPlays = db.prepare('UPDATE tracks SET plays = plays + ? WHERE id = ?');
const listWithdrawalsByArtist = db.prepare('SELECT * FROM withdrawals WHERE artist_id = ? ORDER BY requested_at DESC');
const insertWithdrawal = db.prepare('INSERT INTO withdrawals(artist_id,amount) VALUES(?,?)');
const adminListTracks = db.prepare('SELECT * FROM tracks ORDER BY uploaded_at DESC');
const adminListWithdrawals = db.prepare('SELECT * FROM withdrawals ORDER BY requested_at DESC');
const approveTrack = db.prepare('UPDATE tracks SET approved = 1 WHERE id = ?');
// Seed a demo artist if no artists exist
const countArtists = db.prepare('SELECT COUNT(*) as c FROM artists').get().c;
let demoArtistId;
if (countArtists === 0) {
const res = insertArtist.run('Demo Artist', 'demo@example.com');
demoArtistId = res.lastInsertRowid;
} else {
demoArtistId = db.prepare('SELECT id FROM artists LIMIT 1').get().id;
}
// API endpoints
// Get current artist (demo; in prod you'd authenticate)
app.get('/api/me', (req, res) => {
const artist = getArtistById.get(demoArtistId);
res.json({ artist });
});
// Upload track
app.post('/api/upload', async (req, res) => {
try {
const { title = 'Untitled', artist_id } = req.body;
const artistId = artist_id ? Number(artist_id) : demoArtistId;
if (!req.files || !req.files.track) return res.status(400).json({ error: 'Missing file "track"' });
const trackFile = req.files.track;
// only allow common audio extensions
const allowed = ['.mp3', '.wav', '.m4a', '.ogg', '.flac'];
const ext = path.extname(trackFile.name).toLowerCase();
if (!allowed.includes(ext)) return res.status(400).json({ error: 'Unsupported file type' });
const filename = `${Date.now()}-${trackFile.name.replace(/\s+/g,'_')}`;
const filepath = path.join(__dirname, 'uploads', filename);
await trackFile.mv(filepath);
const info = insertTrack.run(artistId, title, `/uploads/${filename}`, 0.003);
res.json({ success: true, trackId: info.lastInsertRowid });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'upload failed' });
}
});
// List tracks for artist
app.get('/api/tracks/:artistId', (req, res) => {
const artistId = Number(req.params.artistId || demoArtistId);
const tracks = listTracks.all(artistId);
res.json({ tracks });
});
// Increment plays (called by player)
app.post('/api/plays/:trackId', (req, res) => {
const trackId = Number(req.params.trackId);
const inc = Number(req.body.inc || 1);
updateTrackPlays.run(inc, trackId);
res.json({ success: true });
});
// Royalties summary
app.get('/api/royalties/:artistId', (req, res) => {
const artistId = Number(req.params.artistId || demoArtistId);
const rows = listTracks.all(artistId);
// simple royalty = plays * royalty_rate
const royalties = rows.map(r => ({ id: r.id, title: r.title, plays: r.plays, rate: r.royalty_rate, amount: (r.plays * r.royalty_rate) }));
const total = royalties.reduce((s,x)=>s + x.amount, 0);
res.json({ royalties, total });
});
// Withdraw request
app.post('/api/withdraw', (req, res) => {
const artistId = Number(req.body.artistId || demoArtistId);
const amount = Number(req.body.amount || 0);
if (amount <= 0) return res.status(400).json({ error: 'Invalid amount' });
insertWithdrawal.run(artistId, amount);
res.json({ success: true });
});
// List withdrawals for artist
app.get('/api/withdrawals/:artistId', (req, res) => {
const artistId = Number(req.params.artistId || demoArtistId);
const rows = listWithdrawalsByArtist.all(artistId);
res.json({ withdrawals: rows });
});
// Distribution platforms list (static)
app.get('/api/platforms', (req, res) => {
const platforms = [
{ id: 'apple', name: 'Apple Music' },
{ id: 'spotify', name: 'Spotify' },
{ id: 'beatport', name: 'Beatport' },
{ id: 'tidal', name: 'Tidal' },
{ id: 'deezer', name: 'Deezer' },
{ id: 'amazon', name: 'Amazon Music' }
];
res.json({ platforms });
});
// Admin - simple token-protected endpoints
function requireAdmin(req, res, next) {
const token = req.headers['x-admin-token'] || req.query.token;
if (token !== ADMIN_TOKEN) return res.status(401).json({ error: 'unauthorized' });
next();
}
app.get('/api/admin/tracks', requireAdmin, (req,res) => {
res.json({ tracks: adminListTracks.all() });
});
app.post('/api/admin/approve/:trackId', requireAdmin, (req,res) => {
const id = Number(req.params.trackId);
approveTrack.run(id);
res.json({ success: true });
});
app.get('/api/admin/withdrawals', requireAdmin, (req,res) => {
res.json({ withdrawals: adminListWithdrawals.all() });
});
// fallback to index.html
app.get('*', (req,res) => {
if (req.path.startsWith('/api/')) return res.status(404).json({ error: 'not found' });
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
app.listen(PORT, () => {
console.log(`Artist Dashboard running on http://localhost:${PORT} (Admin token: ${ADMIN_TOKEN})`);
}); <!doctype html>
<html>
<head>
<meta charset="utf-8"/>
<title>Artist Dashboard — Demo</title>
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<div class="container">
<header><h1>Artist Dashboard (Demo)</h1></header>
<section id="artist-info">
<h2>Artist</h2>
<div id="artistName">Loading...</div>
</section>
<section id="upload">
<h2>Upload Track</h2>
<form id="uploadForm">
<input type="text" name="title" placeholder="Track title" required />
<input type="file" name="track" accept="audio/*" required />
<button type="submit">Upload</button>
</form>
<div id="uploadMsg"></div>
</section>
<section id="platforms">
<h2>Distribution Platforms</h2>
<ul id="platformList"></ul>
</section>
<section id="tracks">
<h2>Your Tracks</h2>
<div id="tracksList">Loading tracks...</div>
</section>
<section id="royalties">
<h2>Royalties</h2>
<div id="royaltiesData">Loading...</div>
<form id="withdrawForm">
<input type="number" name="amount" step="0.01" placeholder="Amount to withdraw" required />
<button type="submit">Request Withdraw</button>
</form>
<div id="withdrawMsg"></div>
<h3>Withdrawal History</h3>
<div id="withdrawals">Loading...</div>
</section>
<footer>
<small>Admin preview: <a href="/admin.html" target="_blank">Open admin</a></small>
</footer>
</div>
<script src="/app.js"></script>
</body>
</html>
<!doctype html>
<html>
<head>
<meta charset="utf-8"/>
<title>Admin Preview</title>
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<div class="container">
<header><h1>Admin Preview</h1></header>
<p>Enter admin token (from your env ADMIN_TOKEN) to view / approve.</p>
<input id="token" placeholder="Admin token" />
<button id="load">Load</button>
<h2>Uploaded Tracks</h2>
<div id="adminTracks">Not loaded</div>
<h2>Withdrawals</h2>
<div id="adminWithdrawals">Not loaded</div>
</div>
<script>
document.getElementById('load').addEventListener('click', async () => {
const token = document.getElementById('token').value;
if (!token) return alert('enter token');
const headers = { 'x-admin-token': token };
const t = await fetch('/api/admin/tracks', { headers }).then(r => r.json());
document.getElementById('adminTracks').innerHTML = t.tracks.map(tr => {
return `<div class="track">
<b>${tr.title}</b> — <a href="${tr.filename}" target="_blank">file</a>
<div>Approved: ${tr.approved}</div>
<button onclick="approve(${tr.id})">Approve</button>
</div>`;
}).join('');
const w = await fetch('/api/admin/withdrawals', { headers }).then(r => r.json());
document.getElementById('adminWithdrawals').innerHTML = w.withdrawals.map(wd => `<div>${wd.id} — artist ${wd.artist_id} — $${wd.amount} — ${wd.status}</div>`).join('');
window.approve = async (id) => {
await fetch('/api/admin/approve/' + id, { method: 'POST', headers });
alert('approved');
};
});
</script>
</body>
</html> // public/app.js
(async function(){
const me = await (await fetch('/api/me')).json();
const artist = me.artist;
const artistId = artist.id;
document.getElementById('artistName').innerText = `${artist.name} (${artist.email})`;
// platforms
const platforms = await (await fetch('/api/platforms')).json();
document.getElementById('platformList').innerHTML = platforms.platforms.map(p => `<li>${p.name}</li>`).join('');
// upload
document.getElementById('uploadForm').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const data = new FormData(form);
data.set('artist_id', artistId);
const res = await fetch('/api/upload', { method: 'POST', body: data });
const j = await res.json();
document.getElementById('uploadMsg').innerText = j.error ? 'Upload failed: ' + j.error : 'Uploaded!';
await refreshTracks();
});
async function refreshTracks() {
const res = await fetch(`/api/tracks/${artistId}`);
const data = await res.json();
const list = data.tracks;
if (!list.length) {
document.getElementById('tracksList').innerHTML = '<p>No tracks yet.</p>';
return;
}
const html = list.map(tr => {
return `<div class="track">
<div><strong>${tr.title}</strong> ${tr.approved ? '(approved)' : '(pending)'}</div>
<audio data-id="${tr.id}" src="${tr.filename}" controls preload="none"></audio>
<div>Plays: <span id="plays-${tr.id}">${tr.plays}</span></div>
<button onclick="increasePlay(${tr.id})">Simulate Play +1</button>
</div>`;
}).join('');
document.getElementById('tracksList').innerHTML = html;
// attach player loop toggle UI below the first player (global)
attachPlayerControls();
}
window.increasePlay = async function(trackId) {
await fetch('/api/plays/' + trackId, { method: 'POST', headers: {'content-type':'application/json'}, body: JSON.stringify({ inc: 1 })});
const resp = await fetch(`/api/tracks/${artistId}`);
const t = (await resp.json()).tracks.find(x => x.id === trackId);
document.getElementById('plays-' + trackId).innerText = t.plays;
await refreshRoyalties();
};
// royalties
async function refreshRoyalties(){
const r = await (await fetch(`/api/royalties/${artistId}`)).json();
const rows = r.royalties;
const total = r.total.toFixed(2);
document.getElementById('royaltiesData').innerHTML = `<div>Total: $${total}</div>` + rows.map(x => `<div>${x.title} — plays ${x.plays} — $${x.amount.toFixed(2)}</div>`).join('');
}
await refreshTracks();
await refreshRoyalties();
// withdrawals
document.getElementById('withdrawForm').addEventListener('submit', async (e) => {
e.preventDefault();
const amount = e.target.amount.value;
const res = await fetch('/api/withdraw', { method: 'POST', headers: {'content-type':'application/json'}, body: JSON.stringify({ artistId, amount })});
const j = await res.json();
document.getElementById('withdrawMsg').innerText = j.error ? 'Error: ' + j.error : 'Withdrawal requested';
await loadWithdrawals();
});
async function loadWithdrawals(){
const r = await (await fetch(`/api/withdrawals/${artistId}`)).json();
document.getElementById('withdrawals').innerHTML = r.withdrawals.map(w => `<div>${w.id} — $${w.amount} — ${w.status} — ${w.requested_at}</div>`).join('');
}
await loadWithdrawals();
// Player loop toggle controls (global)
function attachPlayerControls(){
// if already exists, skip
if (document.getElementById('playerControls')) return;
const div = document.createElement('div');
div.id = 'playerControls';
div.innerHTML = `
<h3>Playlist Player</h3>
<label><input type="checkbox" id="loopToggle"> Loop playlist</label>
<button id="playAll">Play all</button>
<button id="stopAll">Stop</button>
`;
document.getElementById('tracks').appendChild(div);
const loopToggle = document.getElementById('loopToggle');
const playAll = document.getElementById('playAll');
const stopAll = document.getElementById('stopAll');
let playlist = Array.from(document.querySelectorAll('#tracks audio'));
let currentIndex = 0;
let playing = false;
function playIndex(i){
playlist = Array.from(document.querySelectorAll('#tracks audio'));
if (i < 0 || i >= playlist.length) {
if (loopToggle.checked) { currentIndex = 0; playIndex(0); }
else { playing = false; return; }
} else {
currentIndex = i;
playlist.forEach((a, idx) => { if (idx !== i) { a.pause(); a.currentTime = 0; }});
const audio = playlist[i];
// send play count increment when ended or maybe when play starts? we'll increment on ended for realism
audio.play();
playing = true;
audio.onended = async () => {
// increment play count in DB (server)
const id = Number(audio.dataset.id);
await fetch('/api/plays/' + id, { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ inc: 1 })});
// update UI plays count
const resp = await fetch(`/api/tracks/${artistId}`);
const data = await resp.json();
const t = data.tracks.find(x => x.id === id);
if (t) document.getElementById('plays-' + id).innerText = t.plays;
// next
playIndex(i + 1);
};
}
}
playAll.addEventListener('click', () => {
playlist = Array.from(document.querySelectorAll('#tracks audio'));
if (!playlist.length) return alert('No tracks');
playIndex(0);
});
stopAll.addEventListener('click', () => {
playlist.forEach(a => { a.pause(); a.currentTime = 0; });
playing = false;
});
}
})(); /* public/styles.css */
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, Arial; margin:0; padding:20px; background:#f7f7f8; color:#111; }
.container { max-width:900px; margin:0 auto; background:white; padding:20px; border-radius:8px; box-shadow:0 6px 18px rgba(0,0,0,0.06); }
header { border-bottom:1px solid #eee; padding-bottom:10px; margin-bottom:12px; }
section { margin-bottom:18px; }
.track { padding:8px 0; border-bottom:1px dashed #eee; }
button { padding:8px 12px; border-radius:6px; border:1px solid #ddd; background:#fff; cursor:pointer; }
input[type="text"], input[type="number"] { padding:8px; border-radius:6px; border:1px solid #ddd; width:200px; }
audio { width:100%; max-width:420px; display:block; margin-top:6px; }
footer { margin-top:20px; color:#666; font-size:13px; }
◦