feat: implement VIP access control and configurable generation cooldown
- Add `generation_cooldown_seconds` column to `site_settings` (default 180s) - Add `is_vip` column to `users` table (default 0/false) - Introduce new `requireVip` middleware for enforcing VIP-only routes - Restrict song generation access to VIP users or admins - Add admin UI endpoints to grant/remove VIP status per user - Allow admins to configure generation cooldown period via admin panel - Update generation service to use dynamic cooldown (replacing hardcoded 3 minutes) - Enhance generate page UI with Re-Gen button, improved placeholder text, and rate-limit state updates - Update header navigation to show "My Music" and "Generate" only for VIP/admin users
This commit is contained in:
parent
4046bc20e0
commit
4602087400
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE site_settings ADD COLUMN generation_cooldown_seconds INTEGER NOT NULL DEFAULT 180;
|
||||
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE users ADD COLUMN is_vip INTEGER NOT NULL DEFAULT 0;
|
||||
|
|
@ -20,4 +20,18 @@ function requireAdmin(req, res, next) {
|
|||
next();
|
||||
}
|
||||
|
||||
module.exports = { requireUser, requireAdmin };
|
||||
function requireVip(req, res, next) {
|
||||
if (!req.session.user) {
|
||||
req.session.returnTo = req.originalUrl;
|
||||
return res.redirect('/login');
|
||||
}
|
||||
if (!req.session.user.isVip && req.session.user.role !== 'admin') {
|
||||
const err = new Error('VIP access required.');
|
||||
err.status = 403;
|
||||
err.expose = true;
|
||||
return next(err);
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = { requireUser, requireAdmin, requireVip };
|
||||
|
|
|
|||
|
|
@ -67,6 +67,22 @@ module.exports = function adminPanelRoutes(csrfProtection) {
|
|||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
router.post('/users/:id/vip', csrfProtection, (req, res, next) => {
|
||||
try {
|
||||
users.setVip(Number(req.params.id), true);
|
||||
req.flash('success', 'Account granted VIP access.');
|
||||
res.redirect('/admin');
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
router.post('/users/:id/unvip', csrfProtection, (req, res, next) => {
|
||||
try {
|
||||
users.setVip(Number(req.params.id), false);
|
||||
req.flash('success', 'VIP access removed.');
|
||||
res.redirect('/admin');
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
router.post('/users/:id/delete', csrfProtection, (req, res, next) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
|
|
@ -91,8 +107,10 @@ module.exports = function adminPanelRoutes(csrfProtection) {
|
|||
router.post('/generation', csrfProtection, (req, res, next) => {
|
||||
try {
|
||||
const current = settings.get();
|
||||
const cooldown = parseInt(req.body.generation_cooldown_seconds, 10);
|
||||
settings.set({
|
||||
generation_enabled: req.body.generation_enabled === '1' ? 1 : 0,
|
||||
generation_cooldown_seconds: (!isNaN(cooldown) && cooldown >= 0) ? cooldown : 180,
|
||||
comfyui_base_url: (req.body.comfyui_base_url || '').trim() || current.comfyui_base_url || null,
|
||||
comfyui_workflow_json: current.comfyui_workflow_json || null,
|
||||
comfyui_node_style_prompt: (req.body.node_style_prompt || '').trim() || null,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ const express = require('express');
|
|||
const multer = require('multer');
|
||||
const { z } = require('zod');
|
||||
|
||||
const { requireUser, requireAdmin } = require('../middleware/auth');
|
||||
const { requireUser, requireAdmin, requireVip } = require('../middleware/auth');
|
||||
const songs = require('../services/songs');
|
||||
const playlists = require('../services/playlists');
|
||||
const imageService = require('../services/image');
|
||||
|
|
@ -177,7 +177,7 @@ function resolveGenre(body) {
|
|||
|
||||
module.exports = function adminRoutes(csrfProtection) {
|
||||
const router = express.Router();
|
||||
router.use(requireUser);
|
||||
router.use(requireVip);
|
||||
|
||||
// ---------- Songs ----------
|
||||
router.get('/', (req, res) => res.redirect('/mymusic/songs'));
|
||||
|
|
|
|||
|
|
@ -52,6 +52,10 @@ function requireGeneration(req, res, next) {
|
|||
const err = new Error('Song generation is not enabled.');
|
||||
err.status = 403; err.expose = true; return next(err);
|
||||
}
|
||||
if (!req.session.user.isVip && req.session.user.role !== 'admin') {
|
||||
const err = new Error('VIP access is required to generate songs.');
|
||||
err.status = 403; err.expose = true; return next(err);
|
||||
}
|
||||
if (!req.session.user.emailVerified) {
|
||||
const err = new Error('Please verify your email to use song generation.');
|
||||
err.status = 403; err.expose = true; return next(err);
|
||||
|
|
@ -70,6 +74,7 @@ module.exports = function generateRoutes(csrfProtection) {
|
|||
const genList = generation.listByUser(userId);
|
||||
const count = genList.length;
|
||||
const rl = generation.getRateLimitInfo(userId);
|
||||
const s = settings.get();
|
||||
res.render('generate/index', {
|
||||
title: 'Generate',
|
||||
genList,
|
||||
|
|
@ -78,12 +83,15 @@ module.exports = function generateRoutes(csrfProtection) {
|
|||
canGenerate: count < generation.MAX_GENERATED && rl.rateLimitOk,
|
||||
keyscales: KEYSCALES,
|
||||
rateLimit: rl,
|
||||
cooldownSeconds: s.generation_cooldown_seconds != null ? s.generation_cooldown_seconds : 180,
|
||||
});
|
||||
});
|
||||
|
||||
// ── GET /rate-limit — current rate-limit state (JSON) ─────────────────
|
||||
router.get('/rate-limit', (req, res) => {
|
||||
res.json(generation.getRateLimitInfo(req.session.user.id));
|
||||
const s = settings.get();
|
||||
const rl = generation.getRateLimitInfo(req.session.user.id);
|
||||
res.json({ ...rl, cooldownSeconds: s.generation_cooldown_seconds != null ? s.generation_cooldown_seconds : 180 });
|
||||
});
|
||||
|
||||
// ── POST /submit — kick off a generation job ───────────────────────────
|
||||
|
|
|
|||
|
|
@ -1,16 +1,21 @@
|
|||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { db } = require('../../db');
|
||||
const settings = require('./settings');
|
||||
|
||||
const MEDIA_DIR = process.env.MEDIA_DIR || path.join(__dirname, '..', '..', 'media');
|
||||
const GEN_DIR = path.join(MEDIA_DIR, 'generated');
|
||||
fs.mkdirSync(GEN_DIR, { recursive: true });
|
||||
|
||||
const MAX_GENERATED = 20;
|
||||
const COOLDOWN_MS = 3 * 60 * 1000; // 3 minutes between generations
|
||||
const HOURLY_LIMIT = 10; // max generations per 1-hour rolling window
|
||||
const HOURLY_LIMIT = 10;
|
||||
const HOURLY_WINDOW_MS = 60 * 60 * 1000;
|
||||
|
||||
function getCooldownMs() {
|
||||
const secs = settings.get().generation_cooldown_seconds;
|
||||
return (secs != null ? secs : 180) * 1000;
|
||||
}
|
||||
|
||||
// ── Prepared statements ────────────────────────────────────────────────────
|
||||
const insertStmt = db.prepare(`
|
||||
INSERT INTO generated_songs
|
||||
|
|
@ -51,12 +56,13 @@ function getRateLimitInfo(userId) {
|
|||
const now = Date.now();
|
||||
|
||||
// Cooldown
|
||||
const cooldownMs = getCooldownMs();
|
||||
let cooldownSecsRemaining = 0;
|
||||
const lastRow = lastGenStmt.get(userId);
|
||||
if (lastRow) {
|
||||
const elapsed = now - parseSqliteTs(lastRow.created_at);
|
||||
if (elapsed < COOLDOWN_MS) {
|
||||
cooldownSecsRemaining = Math.ceil((COOLDOWN_MS - elapsed) / 1000);
|
||||
if (elapsed < cooldownMs) {
|
||||
cooldownSecsRemaining = Math.ceil((cooldownMs - elapsed) / 1000);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -184,7 +190,7 @@ async function pollComfyUI(baseUrl, promptId, generationId) {
|
|||
}
|
||||
|
||||
module.exports = {
|
||||
MAX_GENERATED, COOLDOWN_MS, HOURLY_LIMIT, GEN_DIR, MEDIA_DIR,
|
||||
MAX_GENERATED, HOURLY_LIMIT, GEN_DIR, MEDIA_DIR,
|
||||
canGenerate, countByUser, listByUser, findById, create, remove,
|
||||
getRateLimitInfo,
|
||||
updateJobStmt, updateDoneStmt, updateFailStmt,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ const getStmt = db.prepare('SELECT * FROM site_settings WHERE id = 1');
|
|||
const setStmt = db.prepare(`
|
||||
UPDATE site_settings SET
|
||||
generation_enabled = @generation_enabled,
|
||||
generation_cooldown_seconds = @generation_cooldown_seconds,
|
||||
comfyui_base_url = @comfyui_base_url,
|
||||
comfyui_workflow_json = @comfyui_workflow_json,
|
||||
comfyui_node_style_prompt = @comfyui_node_style_prompt,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ const findVerifTokenStmt = db.prepare(
|
|||
);
|
||||
const listAllUsersStmt = db.prepare('SELECT * FROM users ORDER BY created_at ASC');
|
||||
const setDisabledStmt = db.prepare('UPDATE users SET disabled = ? WHERE id = ?');
|
||||
const setVipStmt = db.prepare('UPDATE users SET is_vip = ? WHERE id = ?');
|
||||
const deleteUserStmt = db.prepare('DELETE FROM users WHERE id = ?');
|
||||
const songsByUserStmt = db.prepare('SELECT * FROM songs WHERE uploaded_by = ?');
|
||||
const playlistsByUserStmt = db.prepare('SELECT * FROM playlists WHERE created_by = ?');
|
||||
|
|
@ -80,6 +81,7 @@ function publicView(row) {
|
|||
displayName: row.display_name, role: row.role,
|
||||
emailVerified: row.email_verified === 1,
|
||||
disabled: row.disabled === 1,
|
||||
isVip: row.is_vip === 1,
|
||||
avatarPath: row.avatar_path || null,
|
||||
slug: row.slug || null,
|
||||
songCount: row.song_count != null ? row.song_count : undefined,
|
||||
|
|
@ -163,6 +165,10 @@ function setDisabled(id, disabled) {
|
|||
setDisabledStmt.run(disabled ? 1 : 0, id);
|
||||
}
|
||||
|
||||
function setVip(id, vip) {
|
||||
setVipStmt.run(vip ? 1 : 0, id);
|
||||
}
|
||||
|
||||
function markVerifiedAdmin(id) {
|
||||
db.transaction(() => {
|
||||
markVerifiedStmt.run(id);
|
||||
|
|
@ -207,6 +213,7 @@ module.exports = {
|
|||
listAllUsers,
|
||||
listVerified,
|
||||
setDisabled,
|
||||
setVip,
|
||||
markVerifiedAdmin,
|
||||
deleteUserWithContent,
|
||||
updateAvatar,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
<th>Display Name</th>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>VIP</th>
|
||||
<th>Verified</th>
|
||||
<th>Status</th>
|
||||
<th class="actions">Actions</th>
|
||||
|
|
@ -19,9 +20,21 @@
|
|||
<td><%= u.displayName %></td>
|
||||
<td class="muted small"><%= u.email %></td>
|
||||
<td><%= u.role %></td>
|
||||
<td><%= u.isVip ? '★ VIP' : '—' %></td>
|
||||
<td><%= u.emailVerified ? '✓' : '✗' %></td>
|
||||
<td><%= u.disabled ? 'Disabled' : 'Active' %></td>
|
||||
<td class="actions">
|
||||
<% if (u.isVip) { %>
|
||||
<form method="post" action="/admin/users/<%= u.id %>/unvip" class="inline-form">
|
||||
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||
<button type="submit" class="btn-small">Remove VIP</button>
|
||||
</form>
|
||||
<% } else { %>
|
||||
<form method="post" action="/admin/users/<%= u.id %>/vip" class="inline-form">
|
||||
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||
<button type="submit" class="btn-small">Grant VIP</button>
|
||||
</form>
|
||||
<% } %>
|
||||
<% if (!u.emailVerified) { %>
|
||||
<form method="post" action="/admin/users/<%= u.id %>/verify" class="inline-form">
|
||||
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||
|
|
@ -64,6 +77,13 @@
|
|||
<span>Enable Song Generation <span class="muted small">(shows "Generate" in the nav for verified users)</span></span>
|
||||
</label>
|
||||
|
||||
<label style="max-width:260px">
|
||||
Cooldown Between Generations <span class="muted small">(seconds)</span>
|
||||
<input type="number" name="generation_cooldown_seconds" min="0" max="86400"
|
||||
value="<%= genSettings.generation_cooldown_seconds != null ? genSettings.generation_cooldown_seconds : 180 %>">
|
||||
<span class="muted small">Default: 180 (3 minutes). Set to 0 to disable cooldown.</span>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
ComfyUI Base URL
|
||||
<input type="text" name="comfyui_base_url"
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
<label>
|
||||
Lyrics Prompt
|
||||
<textarea name="lyrics_prompt" id="gen-lyrics" rows="12"
|
||||
placeholder="e.g. A song about late-night drives and neon lights [Verse 1] ..."></textarea>
|
||||
placeholder="[inst] for instrumentals -- For lyrics, enter in a structured format: [verse] lyrics go here and here [pre-chorus] blah blah [chorus] This is the chorus"></textarea>
|
||||
</label>
|
||||
|
||||
<div class="gen-row-fields">
|
||||
|
|
@ -129,7 +129,16 @@
|
|||
</thead>
|
||||
<tbody>
|
||||
<% genList.forEach(function(g) { %>
|
||||
<tr id="gen-row-<%= g.id %>" data-gen-id="<%= g.id %>" data-status="<%= g.status %>">
|
||||
<tr id="gen-row-<%= g.id %>"
|
||||
data-gen-id="<%= g.id %>"
|
||||
data-status="<%= g.status %>"
|
||||
data-style="<%- encodeURIComponent(g.style_prompt || '') %>"
|
||||
data-lyrics="<%- encodeURIComponent(g.lyrics_prompt || '') %>"
|
||||
data-bpm="<%= g.bpm || '' %>"
|
||||
data-keyscale="<%- encodeURIComponent(g.keyscale || '') %>"
|
||||
data-duration="<%= g.duration_seconds || '' %>"
|
||||
data-creativity="<%= g.creativity != null ? g.creativity : 5 %>"
|
||||
data-seed="<%= g.seed != null ? g.seed : -1 %>">
|
||||
<td class="muted small"><%= new Date(g.created_at).toLocaleDateString() %></td>
|
||||
<td class="gen-prompt-cell"><%= (g.style_prompt || '—').slice(0, 60) %><% if ((g.style_prompt || '').length > 60) { %>…<% } %></td>
|
||||
<td><%= g.bpm || '—' %></td>
|
||||
|
|
@ -148,6 +157,7 @@
|
|||
<button type="button" class="btn-small gen-listen-btn" data-gen-id="<%= g.id %>">▶ Listen</button>
|
||||
<a href="/generate/<%= g.id %>/publish" class="btn-small">Publish</a>
|
||||
<% } %>
|
||||
<button type="button" class="btn-small gen-regen-btn" data-gen-id="<%= g.id %>">Re-Gen</button>
|
||||
<button type="button" class="btn-small danger gen-delete-btn" data-gen-id="<%= g.id %>">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -171,6 +181,7 @@
|
|||
hourlySecs: <%= rateLimit.hourlyResetSecsRemaining %>,
|
||||
hourlyCount: <%= rateLimit.hourlyCount %>,
|
||||
hourlyLimit: <%= rateLimit.hourlyLimit %>,
|
||||
cooldownSeconds: <%= cooldownSeconds %>,
|
||||
};
|
||||
|
||||
var cooldownEl = document.getElementById('rl-cooldown-text');
|
||||
|
|
@ -250,6 +261,7 @@
|
|||
rlState.hourlySecs = data.hourlyResetSecsRemaining;
|
||||
rlState.hourlyCount = data.hourlyCount;
|
||||
rlState.hourlyLimit = data.hourlyLimit;
|
||||
if (data.cooldownSeconds != null) rlState.cooldownSeconds = data.cooldownSeconds;
|
||||
updateRateUI();
|
||||
if (cb) cb(data);
|
||||
})
|
||||
|
|
@ -287,8 +299,8 @@
|
|||
return;
|
||||
}
|
||||
statusTxt.textContent = 'Generating… (this may take a minute)';
|
||||
// Start cooldown from now
|
||||
rlState.cooldownSecs = 3 * 60;
|
||||
// Start cooldown from now using the server-configured value
|
||||
rlState.cooldownSecs = rlState.cooldownSeconds;
|
||||
updateRateUI();
|
||||
addHistoryRow(data.generationId);
|
||||
pollStatus(data.generationId);
|
||||
|
|
@ -353,6 +365,13 @@
|
|||
tr.id = 'gen-row-' + id;
|
||||
tr.dataset.genId = id;
|
||||
tr.dataset.status = 'processing';
|
||||
tr.dataset.style = encodeURIComponent(document.getElementById('gen-style').value);
|
||||
tr.dataset.lyrics = encodeURIComponent(document.getElementById('gen-lyrics').value);
|
||||
tr.dataset.bpm = document.getElementById('gen-bpm').value;
|
||||
tr.dataset.keyscale = encodeURIComponent(document.getElementById('gen-keyscale').value);
|
||||
tr.dataset.duration = document.getElementById('gen-duration').value;
|
||||
tr.dataset.creativity = document.getElementById('gen-creativity').value;
|
||||
tr.dataset.seed = document.getElementById('gen-seed').value;
|
||||
tr.innerHTML =
|
||||
'<td class="muted small">' + new Date().toLocaleDateString() + '</td>' +
|
||||
'<td class="gen-prompt-cell">' + escHtml(document.getElementById('gen-style').value.slice(0, 60)) + '</td>' +
|
||||
|
|
@ -360,6 +379,7 @@
|
|||
'<td>' + (document.getElementById('gen-keyscale').value || '—') + '</td>' +
|
||||
'<td class="gen-status-cell"><span class="badge badge-logged-in">Working…</span></td>' +
|
||||
'<td class="actions gen-actions-cell">' +
|
||||
'<button type="button" class="btn-small gen-regen-btn" data-gen-id="' + id + '">Re-Gen</button>' +
|
||||
'<button type="button" class="btn-small danger gen-delete-btn" data-gen-id="' + id + '">Delete</button>' +
|
||||
'</td>';
|
||||
tbody.insertBefore(tr, tbody.firstChild);
|
||||
|
|
@ -376,14 +396,23 @@
|
|||
actionsCell.innerHTML =
|
||||
'<button type="button" class="btn-small gen-listen-btn" data-gen-id="' + id + '">▶ Listen</button>' +
|
||||
'<a href="/generate/' + id + '/publish" class="btn-small">Publish</a>' +
|
||||
'<button type="button" class="btn-small gen-regen-btn" data-gen-id="' + id + '">Re-Gen</button>' +
|
||||
'<button type="button" class="btn-small danger gen-delete-btn" data-gen-id="' + id + '">Delete</button>';
|
||||
} else if (status === 'failed') {
|
||||
statusCell.innerHTML = '<span class="badge badge-private">Failed</span>';
|
||||
actionsCell.innerHTML =
|
||||
'<button type="button" class="btn-small gen-regen-btn" data-gen-id="' + id + '">Re-Gen</button>' +
|
||||
'<button type="button" class="btn-small danger gen-delete-btn" data-gen-id="' + id + '">Delete</button>';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Listen / Delete (delegated) ─────────────────────────────────────
|
||||
document.addEventListener('click', function (e) {
|
||||
// ── Listen / Re-Gen / Delete (delegated) ───────────────────────────
|
||||
// Remove any handler registered by a previous execution of this script
|
||||
// (SPA navigation re-runs the IIFE each time, which would stack listeners).
|
||||
if (document._genClickHandler) {
|
||||
document.removeEventListener('click', document._genClickHandler);
|
||||
}
|
||||
document._genClickHandler = function (e) {
|
||||
var listenBtn = e.target.closest('.gen-listen-btn');
|
||||
if (listenBtn) {
|
||||
var id = listenBtn.dataset.genId;
|
||||
|
|
@ -393,6 +422,47 @@
|
|||
return;
|
||||
}
|
||||
|
||||
var regenBtn = e.target.closest('.gen-regen-btn');
|
||||
if (regenBtn) {
|
||||
var row = document.getElementById('gen-row-' + regenBtn.dataset.genId);
|
||||
if (!row) return;
|
||||
var styleEl = document.getElementById('gen-style');
|
||||
var lyricsEl = document.getElementById('gen-lyrics');
|
||||
var bpmEl = document.getElementById('gen-bpm');
|
||||
var keyscaleEl = document.getElementById('gen-keyscale');
|
||||
var durationEl = document.getElementById('gen-duration');
|
||||
var creativityEl = document.getElementById('gen-creativity');
|
||||
var creativityValEl = document.getElementById('gen-creativity-val');
|
||||
var seedEl = document.getElementById('gen-seed');
|
||||
|
||||
styleEl.value = decodeURIComponent(row.dataset.style || '');
|
||||
lyricsEl.value = decodeURIComponent(row.dataset.lyrics || '');
|
||||
bpmEl.value = row.dataset.bpm || '';
|
||||
durationEl.value = row.dataset.duration || '';
|
||||
seedEl.value = row.dataset.seed || '-1';
|
||||
|
||||
var creativity = row.dataset.creativity != null ? row.dataset.creativity : '5';
|
||||
creativityEl.value = creativity;
|
||||
if (creativityValEl) creativityValEl.textContent = (3 + parseFloat(creativity) * 0.4).toFixed(1);
|
||||
|
||||
// Set keyscale select
|
||||
var keyscale = decodeURIComponent(row.dataset.keyscale || '');
|
||||
if (keyscale) {
|
||||
for (var i = 0; i < keyscaleEl.options.length; i++) {
|
||||
if (keyscaleEl.options[i].value === keyscale) {
|
||||
keyscaleEl.selectedIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll form into view
|
||||
var formCard = document.querySelector('.gen-layout');
|
||||
if (formCard) formCard.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
styleEl.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
var deleteBtn = e.target.closest('.gen-delete-btn');
|
||||
if (deleteBtn) {
|
||||
if (!confirm('Delete this generated song?')) return;
|
||||
|
|
@ -419,7 +489,8 @@
|
|||
})
|
||||
.catch(function (err) { console.error(err); });
|
||||
}
|
||||
});
|
||||
};
|
||||
document.addEventListener('click', document._genClickHandler);
|
||||
|
||||
function escHtml(s) {
|
||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@
|
|||
<a href="/playlists" class="<%= currentPath && currentPath.startsWith('/playlists') ? 'active' : '' %>">Playlists</a>
|
||||
<a href="/profiles" class="<%= currentPath && currentPath.startsWith('/profiles') ? 'active' : '' %>">Profiles</a>
|
||||
<% if (user) { %>
|
||||
<% if (user.isVip || user.role === 'admin') { %>
|
||||
<a href="/mymusic/songs" class="<%= currentPath && currentPath.startsWith('/mymusic') ? 'active' : '' %>">My Music</a>
|
||||
<% if (generationEnabled && user.emailVerified) { %>
|
||||
<% } %>
|
||||
<% if (generationEnabled && user.emailVerified && (user.isVip || user.role === 'admin')) { %>
|
||||
<a href="/generate" class="<%= currentPath && currentPath.startsWith('/generate') ? 'active' : '' %>">Generate</a>
|
||||
<% } %>
|
||||
<% if (user.role === 'admin') { %>
|
||||
|
|
|
|||
Loading…
Reference in New Issue