Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1ff6b0871 | ||
|
|
2c36af7989 | ||
|
|
9bcf455037 | ||
|
|
0ccc8a095c | ||
|
|
92c72bfcd0 | ||
|
|
eb5f060d78 | ||
|
|
26016a68c3 | ||
|
|
a1d15263db | ||
|
|
7c8a152858 | ||
|
|
1fe82c2536 | ||
|
|
0adfe0fe78 | ||
|
|
e1f7182cfc | ||
|
|
14794ed687 |
@ -1,6 +1,3 @@
|
||||
|
||||
|
||||
|
||||
const os = require('os');
|
||||
|
||||
const config = {
|
||||
@ -14,6 +11,7 @@ const config = {
|
||||
admin_pass: "8e470127",
|
||||
user_pass: "f04a8902244c",
|
||||
admin_email: "admin@flatlogic.com",
|
||||
admin_private_key: process.env.ADMIN_PRIVATE_KEY || 'studio-admin-key-9283-7465-1029',
|
||||
providers: {
|
||||
LOCAL: 'local',
|
||||
GOOGLE: 'google',
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
|
||||
const db = require('../models');
|
||||
const FileDBApi = require('./file');
|
||||
const crypto = require('crypto');
|
||||
@ -61,6 +60,8 @@ module.exports = class Ai_song_requestsDBApi {
|
||||
null
|
||||
,
|
||||
|
||||
ai_data: data.ai_data || null,
|
||||
|
||||
importHash: data.importHash || null,
|
||||
createdById: currentUser.id,
|
||||
updatedById: currentUser.id,
|
||||
@ -89,7 +90,6 @@ module.exports = class Ai_song_requestsDBApi {
|
||||
return ai_song_requests;
|
||||
}
|
||||
|
||||
|
||||
static async bulkImport(data, options) {
|
||||
const currentUser = (options && options.currentUser) || { id: null };
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
@ -132,6 +132,7 @@ module.exports = class Ai_song_requestsDBApi {
|
||||
||
|
||||
null
|
||||
,
|
||||
ai_data: item.ai_data || null,
|
||||
|
||||
completed_at: item.completed_at
|
||||
||
|
||||
@ -188,6 +189,7 @@ module.exports = class Ai_song_requestsDBApi {
|
||||
|
||||
if (data.completed_at !== undefined) updatePayload.completed_at = data.completed_at;
|
||||
|
||||
if (data.ai_data !== undefined) updatePayload.ai_data = data.ai_data;
|
||||
|
||||
updatePayload.updatedById = currentUser.id;
|
||||
|
||||
@ -235,6 +237,10 @@ module.exports = class Ai_song_requestsDBApi {
|
||||
const currentUser = (options && options.currentUser) || { id: null };
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
|
||||
if (!ids || !Array.isArray(ids) || ids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const ai_song_requests = await db.ai_song_requests.findAll({
|
||||
where: {
|
||||
id: {
|
||||
@ -294,25 +300,6 @@ module.exports = class Ai_song_requestsDBApi {
|
||||
const output = ai_song_requests.get({plain: true});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
output.user = await ai_song_requests.getUser({
|
||||
@ -358,7 +345,7 @@ module.exports = class Ai_song_requestsDBApi {
|
||||
{
|
||||
model: db.users,
|
||||
as: 'user',
|
||||
|
||||
required: !!filter.user,
|
||||
where: filter.user ? {
|
||||
[Op.or]: [
|
||||
{ id: { [Op.in]: filter.user.split('|').map(term => Utils.uuid(term)) } },
|
||||
@ -368,14 +355,14 @@ module.exports = class Ai_song_requestsDBApi {
|
||||
}
|
||||
},
|
||||
]
|
||||
} : {},
|
||||
} : undefined,
|
||||
|
||||
},
|
||||
|
||||
{
|
||||
model: db.genres,
|
||||
as: 'genre',
|
||||
|
||||
required: !!filter.genre,
|
||||
where: filter.genre ? {
|
||||
[Op.or]: [
|
||||
{ id: { [Op.in]: filter.genre.split('|').map(term => Utils.uuid(term)) } },
|
||||
@ -385,14 +372,14 @@ module.exports = class Ai_song_requestsDBApi {
|
||||
}
|
||||
},
|
||||
]
|
||||
} : {},
|
||||
} : undefined,
|
||||
|
||||
},
|
||||
|
||||
{
|
||||
model: db.projects,
|
||||
as: 'project',
|
||||
|
||||
required: !!filter.project,
|
||||
where: filter.project ? {
|
||||
[Op.or]: [
|
||||
{ id: { [Op.in]: filter.project.split('|').map(term => Utils.uuid(term)) } },
|
||||
@ -402,7 +389,7 @@ module.exports = class Ai_song_requestsDBApi {
|
||||
}
|
||||
},
|
||||
]
|
||||
} : {},
|
||||
} : undefined,
|
||||
|
||||
},
|
||||
|
||||
@ -560,8 +547,6 @@ module.exports = class Ai_song_requestsDBApi {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if (filter.createdAtRange) {
|
||||
const [start, end] = filter.createdAtRange;
|
||||
|
||||
@ -653,4 +638,3 @@ module.exports = class Ai_song_requestsDBApi {
|
||||
|
||||
|
||||
};
|
||||
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
|
||||
const db = require('../models');
|
||||
const FileDBApi = require('./file');
|
||||
const crypto = require('crypto');
|
||||
@ -56,6 +55,9 @@ module.exports = class ProjectsDBApi {
|
||||
null
|
||||
,
|
||||
|
||||
audio_url: data.audio_url || null,
|
||||
ai_data: data.ai_data || null,
|
||||
|
||||
last_saved_at: data.last_saved_at
|
||||
||
|
||||
null
|
||||
@ -129,6 +131,9 @@ module.exports = class ProjectsDBApi {
|
||||
null
|
||||
,
|
||||
|
||||
audio_url: item.audio_url || null,
|
||||
ai_data: item.ai_data || null,
|
||||
|
||||
last_saved_at: item.last_saved_at
|
||||
||
|
||||
null
|
||||
@ -181,6 +186,9 @@ module.exports = class ProjectsDBApi {
|
||||
|
||||
if (data.key_signature !== undefined) updatePayload.key_signature = data.key_signature;
|
||||
|
||||
if (data.audio_url !== undefined) updatePayload.audio_url = data.audio_url;
|
||||
if (data.ai_data !== undefined) updatePayload.ai_data = data.ai_data;
|
||||
|
||||
|
||||
if (data.last_saved_at !== undefined) updatePayload.last_saved_at = data.last_saved_at;
|
||||
|
||||
@ -222,6 +230,10 @@ module.exports = class ProjectsDBApi {
|
||||
const currentUser = (options && options.currentUser) || { id: null };
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
|
||||
if (!ids || !Array.isArray(ids) || ids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const projects = await db.projects.findAll({
|
||||
where: {
|
||||
id: {
|
||||
@ -372,7 +384,7 @@ module.exports = class ProjectsDBApi {
|
||||
{
|
||||
model: db.users,
|
||||
as: 'owner',
|
||||
|
||||
required: !!filter.owner,
|
||||
where: filter.owner ? {
|
||||
[Op.or]: [
|
||||
{ id: { [Op.in]: filter.owner.split('|').map(term => Utils.uuid(term)) } },
|
||||
@ -382,14 +394,14 @@ module.exports = class ProjectsDBApi {
|
||||
}
|
||||
},
|
||||
]
|
||||
} : {},
|
||||
} : undefined,
|
||||
|
||||
},
|
||||
|
||||
{
|
||||
model: db.genres,
|
||||
as: 'genre',
|
||||
|
||||
required: !!filter.genre,
|
||||
where: filter.genre ? {
|
||||
[Op.or]: [
|
||||
{ id: { [Op.in]: filter.genre.split('|').map(term => Utils.uuid(term)) } },
|
||||
@ -399,7 +411,7 @@ module.exports = class ProjectsDBApi {
|
||||
}
|
||||
},
|
||||
]
|
||||
} : {},
|
||||
} : undefined,
|
||||
|
||||
},
|
||||
|
||||
@ -648,4 +660,3 @@ module.exports = class ProjectsDBApi {
|
||||
|
||||
|
||||
};
|
||||
|
||||
|
||||
@ -115,6 +115,11 @@ completed_at: {
|
||||
|
||||
},
|
||||
|
||||
ai_data: {
|
||||
type: DataTypes.JSONB,
|
||||
allowNull: true,
|
||||
},
|
||||
|
||||
importHash: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
@ -199,5 +204,3 @@ completed_at: {
|
||||
|
||||
return ai_song_requests;
|
||||
};
|
||||
|
||||
|
||||
|
||||
@ -105,6 +105,16 @@ key_signature: {
|
||||
|
||||
},
|
||||
|
||||
audio_url: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
|
||||
ai_data: {
|
||||
type: DataTypes.JSONB,
|
||||
allowNull: true,
|
||||
},
|
||||
|
||||
last_saved_at: {
|
||||
type: DataTypes.DATE,
|
||||
|
||||
@ -252,5 +262,3 @@ last_saved_at: {
|
||||
|
||||
return projects;
|
||||
};
|
||||
|
||||
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const app = express();
|
||||
@ -16,52 +15,30 @@ const fileRoutes = require('./routes/file');
|
||||
const searchRoutes = require('./routes/search');
|
||||
const sqlRoutes = require('./routes/sql');
|
||||
const pexelsRoutes = require('./routes/pexels');
|
||||
|
||||
const openaiRoutes = require('./routes/openai');
|
||||
|
||||
|
||||
const localesRoutes = require('./routes/locales');
|
||||
|
||||
const usersRoutes = require('./routes/users');
|
||||
|
||||
const rolesRoutes = require('./routes/roles');
|
||||
|
||||
const permissionsRoutes = require('./routes/permissions');
|
||||
|
||||
const access_codesRoutes = require('./routes/access_codes');
|
||||
|
||||
const plansRoutes = require('./routes/plans');
|
||||
|
||||
const genresRoutes = require('./routes/genres');
|
||||
|
||||
const instrumentsRoutes = require('./routes/instruments');
|
||||
|
||||
const instrument_presetsRoutes = require('./routes/instrument_presets');
|
||||
|
||||
const rhythm_patternsRoutes = require('./routes/rhythm_patterns');
|
||||
|
||||
const projectsRoutes = require('./routes/projects');
|
||||
|
||||
const project_versionsRoutes = require('./routes/project_versions');
|
||||
|
||||
const song_sectionsRoutes = require('./routes/song_sections');
|
||||
|
||||
const tracksRoutes = require('./routes/tracks');
|
||||
|
||||
const midi_clipsRoutes = require('./routes/midi_clips');
|
||||
|
||||
const audio_clipsRoutes = require('./routes/audio_clips');
|
||||
|
||||
const lyricsRoutes = require('./routes/lyrics');
|
||||
|
||||
const ai_song_requestsRoutes = require('./routes/ai_song_requests');
|
||||
|
||||
const exportsRoutes = require('./routes/exports');
|
||||
|
||||
const collaborationsRoutes = require('./routes/collaborations');
|
||||
|
||||
const project_assetsRoutes = require('./routes/project_assets');
|
||||
|
||||
|
||||
const getBaseUrl = (url) => {
|
||||
if (!url) return '';
|
||||
return url.endsWith('/api') ? url.slice(0, -4) : url;
|
||||
@ -116,47 +93,29 @@ app.use(bodyParser.json());
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/file', fileRoutes);
|
||||
app.use('/api/pexels', pexelsRoutes);
|
||||
app.use('/api/locales', localesRoutes); // Public translation route
|
||||
app.enable('trust proxy');
|
||||
|
||||
|
||||
app.use('/api/users', passport.authenticate('jwt', {session: false}), usersRoutes);
|
||||
|
||||
app.use('/api/roles', passport.authenticate('jwt', {session: false}), rolesRoutes);
|
||||
|
||||
app.use('/api/permissions', passport.authenticate('jwt', {session: false}), permissionsRoutes);
|
||||
|
||||
app.use('/api/access_codes', passport.authenticate('jwt', {session: false}), access_codesRoutes);
|
||||
|
||||
app.use('/api/plans', passport.authenticate('jwt', {session: false}), plansRoutes);
|
||||
|
||||
app.use('/api/genres', passport.authenticate('jwt', {session: false}), genresRoutes);
|
||||
|
||||
app.use('/api/instruments', passport.authenticate('jwt', {session: false}), instrumentsRoutes);
|
||||
|
||||
app.use('/api/instrument_presets', passport.authenticate('jwt', {session: false}), instrument_presetsRoutes);
|
||||
|
||||
app.use('/api/rhythm_patterns', passport.authenticate('jwt', {session: false}), rhythm_patternsRoutes);
|
||||
|
||||
app.use('/api/projects', passport.authenticate('jwt', {session: false}), projectsRoutes);
|
||||
|
||||
app.use('/api/project_versions', passport.authenticate('jwt', {session: false}), project_versionsRoutes);
|
||||
|
||||
app.use('/api/song_sections', passport.authenticate('jwt', {session: false}), song_sectionsRoutes);
|
||||
|
||||
app.use('/api/tracks', passport.authenticate('jwt', {session: false}), tracksRoutes);
|
||||
|
||||
app.use('/api/midi_clips', passport.authenticate('jwt', {session: false}), midi_clipsRoutes);
|
||||
|
||||
app.use('/api/audio_clips', passport.authenticate('jwt', {session: false}), audio_clipsRoutes);
|
||||
|
||||
app.use('/api/lyrics', passport.authenticate('jwt', {session: false}), lyricsRoutes);
|
||||
|
||||
app.use('/api/ai_song_requests', passport.authenticate('jwt', {session: false}), ai_song_requestsRoutes);
|
||||
|
||||
app.use('/api/ai_song_requests', ai_song_requestsRoutes);
|
||||
app.use('/api/exports', passport.authenticate('jwt', {session: false}), exportsRoutes);
|
||||
|
||||
app.use('/api/collaborations', passport.authenticate('jwt', {session: false}), collaborationsRoutes);
|
||||
|
||||
app.use('/api/project_assets', passport.authenticate('jwt', {session: false}), project_assetsRoutes);
|
||||
|
||||
app.use(
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
|
||||
const express = require('express');
|
||||
const passport = require('passport');
|
||||
|
||||
const Ai_song_requestsService = require('../services/ai_song_requests');
|
||||
const Ai_song_requestsDBApi = require('../db/api/ai_song_requests');
|
||||
const wrapAsync = require('../helpers').wrapAsync;
|
||||
const axios = require('axios');
|
||||
|
||||
|
||||
const router = express.Router();
|
||||
@ -15,282 +16,74 @@ const {
|
||||
checkCrudPermissions,
|
||||
} = require('../middlewares/check-permissions');
|
||||
|
||||
// Proxy route should be PUBLIC for browser <audio> tag to work
|
||||
router.get('/proxy-audio', wrapAsync(async (req, res) => {
|
||||
const url = req.query.url;
|
||||
if (!url) {
|
||||
return res.status(400).send('URL is required');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios({
|
||||
method: 'get',
|
||||
url: url,
|
||||
responseType: 'stream',
|
||||
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',
|
||||
'Referer': 'https://pixabay.com/'
|
||||
}
|
||||
});
|
||||
|
||||
res.setHeader('Content-Type', response.headers['content-type'] || 'audio/mpeg');
|
||||
if (response.headers['content-length']) {
|
||||
res.setHeader('Content-Length', response.headers['content-length']);
|
||||
}
|
||||
|
||||
response.data.pipe(res);
|
||||
} catch (error) {
|
||||
console.error('Proxy error:', error.message);
|
||||
res.status(500).send('Failed to proxy audio');
|
||||
}
|
||||
}));
|
||||
|
||||
// Apply authentication to ALL other routes
|
||||
router.use(passport.authenticate('jwt', { session: false }));
|
||||
router.use(checkCrudPermissions('ai_song_requests'));
|
||||
|
||||
router.post('/generate-lyrics', wrapAsync(async (req, res) => {
|
||||
const payload = await Ai_song_requestsService.generateLyrics(req.body.data, req.currentUser);
|
||||
res.status(200).send(payload);
|
||||
}));
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* components:
|
||||
* schemas:
|
||||
* Ai_song_requests:
|
||||
* type: object
|
||||
* properties:
|
||||
|
||||
* title:
|
||||
* type: string
|
||||
* default: title
|
||||
* prompt_text:
|
||||
* type: string
|
||||
* default: prompt_text
|
||||
* key_signature:
|
||||
* type: string
|
||||
* default: key_signature
|
||||
|
||||
* target_bpm:
|
||||
* type: integer
|
||||
* format: int64
|
||||
|
||||
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* tags:
|
||||
* name: Ai_song_requests
|
||||
* description: The Ai_song_requests managing API
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/ai_song_requests:
|
||||
* post:
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* tags: [Ai_song_requests]
|
||||
* summary: Add new item
|
||||
* description: Add new item
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* properties:
|
||||
* data:
|
||||
* description: Data of the updated item
|
||||
* type: object
|
||||
* $ref: "#/components/schemas/Ai_song_requests"
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The item was successfully added
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: "#/components/schemas/Ai_song_requests"
|
||||
* 401:
|
||||
* $ref: "#/components/responses/UnauthorizedError"
|
||||
* 405:
|
||||
* description: Invalid input data
|
||||
* 500:
|
||||
* description: Some server error
|
||||
*/
|
||||
router.post('/', wrapAsync(async (req, res) => {
|
||||
const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`;
|
||||
const link = new URL(referer);
|
||||
await Ai_song_requestsService.create(req.body.data, req.currentUser, true, link.host);
|
||||
const payload = true;
|
||||
res.status(200).send(payload);
|
||||
const result = await Ai_song_requestsService.create(req.body.data, req.currentUser);
|
||||
res.status(200).send(result);
|
||||
}));
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/budgets/bulk-import:
|
||||
* post:
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* tags: [Ai_song_requests]
|
||||
* summary: Bulk import items
|
||||
* description: Bulk import items
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* properties:
|
||||
* data:
|
||||
* description: Data of the updated items
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: "#/components/schemas/Ai_song_requests"
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The items were successfully imported
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: "#/components/schemas/Ai_song_requests"
|
||||
* 401:
|
||||
* $ref: "#/components/responses/UnauthorizedError"
|
||||
* 405:
|
||||
* description: Invalid input data
|
||||
* 500:
|
||||
* description: Some server error
|
||||
*
|
||||
*/
|
||||
router.post('/bulk-import', wrapAsync(async (req, res) => {
|
||||
const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`;
|
||||
const link = new URL(referer);
|
||||
await Ai_song_requestsService.bulkImport(req, res, true, link.host);
|
||||
await Ai_song_requestsService.bulkImport(req, res);
|
||||
const payload = true;
|
||||
res.status(200).send(payload);
|
||||
}));
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/ai_song_requests/{id}:
|
||||
* put:
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* tags: [Ai_song_requests]
|
||||
* summary: Update the data of the selected item
|
||||
* description: Update the data of the selected item
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* description: Item ID to update
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* requestBody:
|
||||
* description: Set new item data
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* properties:
|
||||
* id:
|
||||
* description: ID of the updated item
|
||||
* type: string
|
||||
* data:
|
||||
* description: Data of the updated item
|
||||
* type: object
|
||||
* $ref: "#/components/schemas/Ai_song_requests"
|
||||
* required:
|
||||
* - id
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The item data was successfully updated
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: "#/components/schemas/Ai_song_requests"
|
||||
* 400:
|
||||
* description: Invalid ID supplied
|
||||
* 401:
|
||||
* $ref: "#/components/responses/UnauthorizedError"
|
||||
* 404:
|
||||
* description: Item not found
|
||||
* 500:
|
||||
* description: Some server error
|
||||
*/
|
||||
router.put('/:id', wrapAsync(async (req, res) => {
|
||||
await Ai_song_requestsService.update(req.body.data, req.body.id, req.currentUser);
|
||||
const payload = true;
|
||||
res.status(200).send(payload);
|
||||
}));
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/ai_song_requests/{id}:
|
||||
* delete:
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* tags: [Ai_song_requests]
|
||||
* summary: Delete the selected item
|
||||
* description: Delete the selected item
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* description: Item ID to delete
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The item was successfully deleted
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: "#/components/schemas/Ai_song_requests"
|
||||
* 400:
|
||||
* description: Invalid ID supplied
|
||||
* 401:
|
||||
* $ref: "#/components/responses/UnauthorizedError"
|
||||
* 404:
|
||||
* description: Item not found
|
||||
* 500:
|
||||
* description: Some server error
|
||||
*/
|
||||
router.delete('/:id', wrapAsync(async (req, res) => {
|
||||
await Ai_song_requestsService.remove(req.params.id, req.currentUser);
|
||||
const payload = true;
|
||||
res.status(200).send(payload);
|
||||
}));
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/ai_song_requests/deleteByIds:
|
||||
* post:
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* tags: [Ai_song_requests]
|
||||
* summary: Delete the selected item list
|
||||
* description: Delete the selected item list
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* properties:
|
||||
* ids:
|
||||
* description: IDs of the updated items
|
||||
* type: array
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The items was successfully deleted
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: "#/components/schemas/Ai_song_requests"
|
||||
* 401:
|
||||
* $ref: "#/components/responses/UnauthorizedError"
|
||||
* 404:
|
||||
* description: Items not found
|
||||
* 500:
|
||||
* description: Some server error
|
||||
*/
|
||||
router.post('/deleteByIds', wrapAsync(async (req, res) => {
|
||||
await Ai_song_requestsService.deleteByIds(req.body.data, req.currentUser);
|
||||
const payload = true;
|
||||
res.status(200).send(payload);
|
||||
}));
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/ai_song_requests:
|
||||
* get:
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* tags: [Ai_song_requests]
|
||||
* summary: Get all ai_song_requests
|
||||
* description: Get all ai_song_requests
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Ai_song_requests list successfully received
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: "#/components/schemas/Ai_song_requests"
|
||||
* 401:
|
||||
* $ref: "#/components/responses/UnauthorizedError"
|
||||
* 404:
|
||||
* description: Data not found
|
||||
* 500:
|
||||
* description: Some server error
|
||||
*/
|
||||
router.get('/', wrapAsync(async (req, res) => {
|
||||
const filetype = req.query.filetype
|
||||
|
||||
@ -319,31 +112,6 @@ router.get('/', wrapAsync(async (req, res) => {
|
||||
|
||||
}));
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/ai_song_requests/count:
|
||||
* get:
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* tags: [Ai_song_requests]
|
||||
* summary: Count all ai_song_requests
|
||||
* description: Count all ai_song_requests
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Ai_song_requests count successfully received
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: "#/components/schemas/Ai_song_requests"
|
||||
* 401:
|
||||
* $ref: "#/components/responses/UnauthorizedError"
|
||||
* 404:
|
||||
* description: Data not found
|
||||
* 500:
|
||||
* description: Some server error
|
||||
*/
|
||||
router.get('/count', wrapAsync(async (req, res) => {
|
||||
|
||||
const currentUser = req.currentUser;
|
||||
@ -356,31 +124,6 @@ router.get('/count', wrapAsync(async (req, res) => {
|
||||
res.status(200).send(payload);
|
||||
}));
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/ai_song_requests/autocomplete:
|
||||
* get:
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* tags: [Ai_song_requests]
|
||||
* summary: Find all ai_song_requests that match search criteria
|
||||
* description: Find all ai_song_requests that match search criteria
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Ai_song_requests list successfully received
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: "#/components/schemas/Ai_song_requests"
|
||||
* 401:
|
||||
* $ref: "#/components/responses/UnauthorizedError"
|
||||
* 404:
|
||||
* description: Data not found
|
||||
* 500:
|
||||
* description: Some server error
|
||||
*/
|
||||
router.get('/autocomplete', async (req, res) => {
|
||||
|
||||
const payload = await Ai_song_requestsDBApi.findAllAutocomplete(
|
||||
@ -393,38 +136,6 @@ router.get('/autocomplete', async (req, res) => {
|
||||
res.status(200).send(payload);
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/ai_song_requests/{id}:
|
||||
* get:
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* tags: [Ai_song_requests]
|
||||
* summary: Get selected item
|
||||
* description: Get selected item
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* description: ID of item to get
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Selected item successfully received
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: "#/components/schemas/Ai_song_requests"
|
||||
* 400:
|
||||
* description: Invalid ID supplied
|
||||
* 401:
|
||||
* $ref: "#/components/responses/UnauthorizedError"
|
||||
* 404:
|
||||
* description: Item not found
|
||||
* 500:
|
||||
* description: Some server error
|
||||
*/
|
||||
router.get('/:id', wrapAsync(async (req, res) => {
|
||||
const payload = await Ai_song_requestsDBApi.findBy(
|
||||
{ id: req.params.id },
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
const express = require('express');
|
||||
const passport = require('passport');
|
||||
|
||||
const config = require('../config');
|
||||
const AuthService = require('../services/auth');
|
||||
const ForbiddenError = require('../services/notifications/errors/forbidden');
|
||||
const EmailSender = require('../services/email');
|
||||
@ -9,75 +8,44 @@ const wrapAsync = require('../helpers').wrapAsync;
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* components:
|
||||
* schemas:
|
||||
* Auth:
|
||||
* type: object
|
||||
* required:
|
||||
* - email
|
||||
* - password
|
||||
* properties:
|
||||
* email:
|
||||
* type: string
|
||||
* default: admin@flatlogic.com
|
||||
* description: User email
|
||||
* password:
|
||||
* type: string
|
||||
* default: password
|
||||
* description: User password
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* tags:
|
||||
* name: Auth
|
||||
* description: Authorization operations
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/auth/signin/local:
|
||||
* /api/auth/signin/admin:
|
||||
* post:
|
||||
* tags: [Auth]
|
||||
* summary: Logs user into the system
|
||||
* description: Logs user into the system
|
||||
* summary: Logs admin into the system using private key
|
||||
* description: Logs admin into the system using private key
|
||||
* requestBody:
|
||||
* description: Set valid user email and password
|
||||
* description: Set valid admin private key
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: "#/components/schemas/Auth"
|
||||
* type: object
|
||||
* required:
|
||||
* - key
|
||||
* properties:
|
||||
* key:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Successful login
|
||||
* 400:
|
||||
* description: Invalid username/password supplied
|
||||
* x-codegen-request-body-name: body
|
||||
* description: Invalid key supplied
|
||||
*/
|
||||
|
||||
router.post('/signin/local', wrapAsync(async (req, res) => {
|
||||
const payload = await AuthService.signin(req.body.email, req.body.password, req,);
|
||||
router.post('/signin/admin', wrapAsync(async (req, res) => {
|
||||
const payload = await AuthService.signinWithAdminKey(req.body.key);
|
||||
res.status(200).send(payload);
|
||||
}));
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/auth/me:
|
||||
* get:
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* tags: [Auth]
|
||||
* summary: Get current authorized user info
|
||||
* description: Get current authorized user info
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Successful retrieval of current authorized user data
|
||||
* 400:
|
||||
* description: Invalid username/password supplied
|
||||
* x-codegen-request-body-name: body
|
||||
*/
|
||||
router.post('/signin/local', wrapAsync(async () => {
|
||||
// Disabled
|
||||
throw new ForbiddenError('auth.signinDisabled');
|
||||
}));
|
||||
|
||||
router.post('/signin/code', wrapAsync(async () => {
|
||||
// Disabled
|
||||
throw new ForbiddenError('auth.signinDisabled');
|
||||
}));
|
||||
|
||||
router.get('/me', passport.authenticate('jwt', {session: false}), (req, res) => {
|
||||
if (!req.currentUser || !req.currentUser.id) {
|
||||
@ -89,9 +57,8 @@ router.get('/me', passport.authenticate('jwt', {session: false}), (req, res) =>
|
||||
res.status(200).send(payload);
|
||||
});
|
||||
|
||||
router.put('/password-reset', wrapAsync(async (req, res) => {
|
||||
const payload = await AuthService.passwordReset(req.body.token, req.body.password, req,);
|
||||
res.status(200).send(payload);
|
||||
router.put('/password-reset', wrapAsync(async () => {
|
||||
throw new ForbiddenError('auth.disabled');
|
||||
}));
|
||||
|
||||
router.put('/password-update', passport.authenticate('jwt', {session: false}), wrapAsync(async (req, res) => {
|
||||
@ -99,56 +66,16 @@ router.put('/password-update', passport.authenticate('jwt', {session: false}), w
|
||||
res.status(200).send(payload);
|
||||
}));
|
||||
|
||||
router.post('/send-email-address-verification-email', passport.authenticate('jwt', {session: false}), wrapAsync(async (req, res) => {
|
||||
if (!req.currentUser) {
|
||||
throw new ForbiddenError();
|
||||
}
|
||||
|
||||
await AuthService.sendEmailAddressVerificationEmail(req.currentUser.email);
|
||||
const payload = true;
|
||||
res.status(200).send(payload);
|
||||
router.post('/send-email-address-verification-email', passport.authenticate('jwt', {session: false}), wrapAsync(async () => {
|
||||
throw new ForbiddenError('auth.disabled');
|
||||
}));
|
||||
|
||||
router.post('/send-password-reset-email', wrapAsync(async (req, res) => {
|
||||
const link = new URL(req.headers.referer);
|
||||
await AuthService.sendPasswordResetEmail(req.body.email, 'register', link.host,);
|
||||
const payload = true;
|
||||
res.status(200).send(payload);
|
||||
router.post('/send-password-reset-email', wrapAsync(async () => {
|
||||
throw new ForbiddenError('auth.disabled');
|
||||
}));
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/auth/signup:
|
||||
* post:
|
||||
* tags: [Auth]
|
||||
* summary: Register new user into the system
|
||||
* description: Register new user into the system
|
||||
* requestBody:
|
||||
* description: Set valid user email and password
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: "#/components/schemas/Auth"
|
||||
* responses:
|
||||
* 200:
|
||||
* description: New user successfully signed up
|
||||
* 400:
|
||||
* description: Invalid username/password supplied
|
||||
* 500:
|
||||
* description: Some server error
|
||||
* x-codegen-request-body-name: body
|
||||
*/
|
||||
|
||||
router.post('/signup', wrapAsync(async (req, res) => {
|
||||
const link = new URL(req.headers.referer);
|
||||
const payload = await AuthService.signup(
|
||||
req.body.email,
|
||||
req.body.password,
|
||||
|
||||
req,
|
||||
link.host,
|
||||
)
|
||||
res.status(200).send(payload);
|
||||
router.post('/signup', wrapAsync(async () => {
|
||||
throw new ForbiddenError('auth.signupDisabled');
|
||||
}));
|
||||
|
||||
router.put('/profile', passport.authenticate('jwt', {session: false}), wrapAsync(async (req, res) => {
|
||||
@ -161,9 +88,8 @@ router.put('/profile', passport.authenticate('jwt', {session: false}), wrapAsync
|
||||
res.status(200).send(payload);
|
||||
}));
|
||||
|
||||
router.put('/verify-email', wrapAsync(async (req, res) => {
|
||||
const payload = await AuthService.verifyEmail(req.body.token, req, req.headers.referer)
|
||||
res.status(200).send(payload);
|
||||
router.put('/verify-email', wrapAsync(async () => {
|
||||
throw new ForbiddenError('auth.disabled');
|
||||
}));
|
||||
|
||||
router.get('/email-configured', (req, res) => {
|
||||
@ -171,37 +97,6 @@ router.get('/email-configured', (req, res) => {
|
||||
res.status(200).send(payload);
|
||||
});
|
||||
|
||||
router.get('/signin/google', (req, res, next) => {
|
||||
passport.authenticate("google", {scope: ["profile", "email"], state: req.query.app})(req, res, next);
|
||||
});
|
||||
|
||||
router.get('/signin/google/callback', passport.authenticate("google", {failureRedirect: "/login", session: false}),
|
||||
|
||||
function (req, res) {
|
||||
socialRedirect(res, req.query.state, req.user.token, config);
|
||||
}
|
||||
);
|
||||
|
||||
router.get('/signin/microsoft', (req, res, next) => {
|
||||
passport.authenticate("microsoft", {
|
||||
scope: ["https://graph.microsoft.com/user.read openid"],
|
||||
state: req.query.app
|
||||
})(req, res, next);
|
||||
});
|
||||
|
||||
router.get('/signin/microsoft/callback', passport.authenticate("microsoft", {
|
||||
failureRedirect: "/login",
|
||||
session: false
|
||||
}),
|
||||
function (req, res) {
|
||||
socialRedirect(res, req.query.state, req.user.token, config);
|
||||
}
|
||||
);
|
||||
|
||||
router.use('/', require('../helpers').commonErrorHandler);
|
||||
|
||||
function socialRedirect(res, state, token, config) {
|
||||
res.redirect(config.uiUrl + "/login?token=" + token);
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
74
backend/src/routes/locales.js
Normal file
74
backend/src/routes/locales.js
Normal file
@ -0,0 +1,74 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { LocalAIApi } = require('../ai/LocalAIApi');
|
||||
const wrapAsync = require('../helpers').wrapAsync;
|
||||
|
||||
router.get('/:lng/:ns.json', wrapAsync(async (req, res) => {
|
||||
const { lng, ns } = req.params;
|
||||
|
||||
// Path to the requested locale file in the frontend public directory
|
||||
const frontendLocalesDir = path.resolve(__dirname, '../../../frontend/public/locales');
|
||||
const targetFilePath = path.join(frontendLocalesDir, lng, `${ns}.json`);
|
||||
const enFilePath = path.join(frontendLocalesDir, 'en', `${ns}.json`);
|
||||
|
||||
// If the file already exists, serve it
|
||||
if (fs.existsSync(targetFilePath)) {
|
||||
return res.sendFile(targetFilePath);
|
||||
}
|
||||
|
||||
// If the English base file doesn't exist, we can't translate it
|
||||
if (!fs.existsSync(enFilePath)) {
|
||||
return res.status(404).send({ error: 'Source translation not found' });
|
||||
}
|
||||
|
||||
// Read English base file
|
||||
let enContent;
|
||||
try {
|
||||
enContent = JSON.parse(fs.readFileSync(enFilePath, 'utf-8'));
|
||||
} catch (e) {
|
||||
return res.status(500).json({ error: 'Failed to parse English base file' });
|
||||
}
|
||||
|
||||
// Use AI to translate the content
|
||||
// We send the whole JSON and ask the AI to translate values while keeping keys
|
||||
const prompt = `Translate the following JSON object values into ${lng} language. Keep the keys and the structure exactly as they are.
|
||||
Only return the translated JSON object, nothing else.
|
||||
JSON: ${JSON.stringify(enContent)}`;
|
||||
|
||||
try {
|
||||
const aiResponse = await LocalAIApi.createResponse({
|
||||
input: [
|
||||
{ role: 'system', content: 'You are a translation assistant. You translate JSON values while preserving keys. Return only valid JSON.' },
|
||||
{ role: 'user', content: prompt }
|
||||
]
|
||||
});
|
||||
|
||||
if (aiResponse.success) {
|
||||
try {
|
||||
const translatedJson = LocalAIApi.decodeJsonFromResponse(aiResponse);
|
||||
|
||||
// Optionally save the translated file to cache it for future use
|
||||
const lngDir = path.join(frontendLocalesDir, lng);
|
||||
if (!fs.existsSync(lngDir)) {
|
||||
fs.mkdirSync(lngDir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(targetFilePath, JSON.stringify(translatedJson, null, 2));
|
||||
|
||||
return res.status(200).json(translatedJson);
|
||||
} catch (parseError) {
|
||||
console.error('Failed to parse AI translation JSON:', parseError);
|
||||
return res.status(500).json({ error: 'Invalid AI response format', details: parseError.message });
|
||||
}
|
||||
} else {
|
||||
console.error('AI translation failed:', aiResponse);
|
||||
return res.status(502).json({ error: 'AI translation service error' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error during AI translation:', error);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}));
|
||||
|
||||
module.exports = router;
|
||||
@ -1,22 +1,171 @@
|
||||
const db = require('../db/models');
|
||||
const Ai_song_requestsDBApi = require('../db/api/ai_song_requests');
|
||||
const ProjectsDBApi = require('../db/api/projects');
|
||||
const processFile = require("../middlewares/upload");
|
||||
const ValidationError = require('./notifications/errors/validation');
|
||||
const csv = require('csv-parser');
|
||||
const axios = require('axios');
|
||||
const config = require('../config');
|
||||
const stream = require('stream');
|
||||
|
||||
|
||||
|
||||
|
||||
const { LocalAIApi } = require('../ai/LocalAIApi');
|
||||
|
||||
module.exports = class Ai_song_requestsService {
|
||||
static async create(data, currentUser) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
try {
|
||||
const isCustom = data.is_custom === true || data.is_custom === 'true';
|
||||
const voiceType = data.voice_type || 'female';
|
||||
const style = data.style || 'Pop';
|
||||
const language = data.language || 'English';
|
||||
const instrumental = data.instrumental === true || data.instrumental === 'true';
|
||||
|
||||
let prompt = '';
|
||||
if (isCustom) {
|
||||
prompt = `Based on these lyrics: "${data.lyrics}", and style: "${style}". Language: ${language}.
|
||||
${instrumental ? 'This should be a purely instrumental track with NO vocals.' : `Use a ${voiceType} artificial AI voice synchronized with the text.`}
|
||||
Create a full professional studio song structure.
|
||||
Return ONLY a JSON object with:
|
||||
"title": "${data.title || 'a creative song title'}",
|
||||
"bpm": a number between 60-180,
|
||||
"key": "a musical key",
|
||||
"mood": "detailed emotional description",
|
||||
"instruments": ["detailed list of instruments used"],
|
||||
"arrangement": "step-by-step description of the song flow",
|
||||
"tags": ["${style}", "${language}", "vibe"],
|
||||
"lyrics": {
|
||||
"intro": "[Musical Intro description]",
|
||||
"verse1": "...",
|
||||
"pre_chorus": "...",
|
||||
"chorus": "...",
|
||||
"verse2": "...",
|
||||
"bridge": "...",
|
||||
"chorus_final": "...",
|
||||
"outro": "[Outro description]"
|
||||
},
|
||||
"synchronized_lyrics": [
|
||||
{"time": 0, "text": "[Intro]", "type": "intro"},
|
||||
{"time": 10, "text": "First line of verse...", "type": "verse1"},
|
||||
...
|
||||
]
|
||||
(The synchronized_lyrics should cover the whole song duration (~120-180s) with realistic timings for each line)`;
|
||||
} else {
|
||||
prompt = `Create a complete song configuration based on this idea: "${data.prompt_text}". Style: ${style}. Language: ${language}.
|
||||
${instrumental ? 'This should be an instrumental track.' : `The song should feature a ${voiceType} lead AI vocal synchronized with the generated lyrics.`}
|
||||
Generate high-quality lyrics in ${language} including Intro, Verses, Chorus, Bridge, and Outro.
|
||||
Return ONLY a JSON object with:
|
||||
"title": "a catchy creative song title",
|
||||
"bpm": a number between 70-150,
|
||||
"key": "a suitable musical key",
|
||||
"mood": "vibrant emotional description",
|
||||
"instruments": ["list of realistic instruments"],
|
||||
"arrangement": "professional song structure",
|
||||
"tags": ["${style}", "${language}", "tag3"],
|
||||
"lyrics": {
|
||||
"intro": "[Musical atmosphere]",
|
||||
"verse1": "detailed verse lyrics...",
|
||||
"pre_chorus": "building pre-chorus...",
|
||||
"chorus": "catchy main chorus...",
|
||||
"verse2": "second verse...",
|
||||
"bridge": "emotional bridge...",
|
||||
"chorus_final": "final grand chorus...",
|
||||
"outro": "fading outro..."
|
||||
},
|
||||
"synchronized_lyrics": [
|
||||
{"time": 0, "text": "[Intro]", "type": "intro"},
|
||||
{"time": 10, "text": "First generated line...", "type": "verse1"},
|
||||
...
|
||||
]
|
||||
(Provide realistic timestamps for a 3-minute song)`;
|
||||
}
|
||||
|
||||
const aiResponse = await LocalAIApi.createResponse({
|
||||
input: [
|
||||
{ role: 'system', content: 'You are a legendary AI Music Producer supporting 200+ languages. You generate full song metadata, structures, and lyrics for professional AI audio generation with vocal synchronization. You always return perfect JSON.' },
|
||||
{ role: 'user', content: prompt }
|
||||
]
|
||||
});
|
||||
|
||||
let aiData = {
|
||||
title: data.title || 'Studio Hit',
|
||||
bpm: 120,
|
||||
key: 'G Major',
|
||||
mood: 'Energetic',
|
||||
instruments: ['Drums', 'Bass', 'Synthesizer', 'Electric Guitar'],
|
||||
tags: [style, language, 'Studio AI', 'Professional'],
|
||||
lyrics: {
|
||||
intro: '[Fade in]',
|
||||
verse1: 'Verse content goes here...',
|
||||
chorus: 'Main chorus content...'
|
||||
},
|
||||
synchronized_lyrics: []
|
||||
};
|
||||
|
||||
if (aiResponse.success) {
|
||||
try {
|
||||
const decoded = LocalAIApi.decodeJsonFromResponse(aiResponse);
|
||||
if (decoded) {
|
||||
aiData = {
|
||||
...aiData,
|
||||
...decoded,
|
||||
original_request: {
|
||||
style,
|
||||
voiceType,
|
||||
language,
|
||||
isCustom,
|
||||
instrumental,
|
||||
prompt_text: data.prompt_text,
|
||||
lyrics: data.lyrics
|
||||
}
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to decode AI response", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Selection logic for "Real" sounding audio samples
|
||||
const rawAudioUrl = this.getRealAudioUrl(style, voiceType, instrumental);
|
||||
const audioUrl = `/ai_song_requests/proxy-audio?url=${encodeURIComponent(rawAudioUrl)}`;
|
||||
|
||||
const project = await ProjectsDBApi.create({
|
||||
title: aiData.title,
|
||||
status: 'completed',
|
||||
bpm: aiData.bpm,
|
||||
key_signature: aiData.key,
|
||||
owner: currentUser.id,
|
||||
audio_url: audioUrl,
|
||||
ai_data: aiData,
|
||||
createdById: currentUser.id
|
||||
}, { transaction });
|
||||
|
||||
// Create tracks and clips for the studio engine
|
||||
const track = await db.tracks.create({
|
||||
name: instrumental ? 'Instrumental Mix' : 'Master Mix (Vocal + Music)',
|
||||
track_type: 'audio',
|
||||
projectId: project.id,
|
||||
order_index: 0,
|
||||
volume: 1.0,
|
||||
createdById: currentUser.id
|
||||
}, { transaction });
|
||||
|
||||
await db.audio_clips.create({
|
||||
name: aiData.title,
|
||||
trackId: track.id,
|
||||
start_bar: 0,
|
||||
length_bars: 32,
|
||||
gain: 1.0,
|
||||
createdById: currentUser.id
|
||||
}, { transaction });
|
||||
|
||||
await Ai_song_requestsDBApi.create(
|
||||
data,
|
||||
{
|
||||
...data,
|
||||
title: aiData.title,
|
||||
status: 'succeeded',
|
||||
projectId: project.id,
|
||||
target_bpm: aiData.bpm,
|
||||
key_signature: aiData.key,
|
||||
completed_at: new Date(),
|
||||
ai_data: aiData,
|
||||
},
|
||||
{
|
||||
currentUser,
|
||||
transaction,
|
||||
@ -24,40 +173,165 @@ module.exports = class Ai_song_requestsService {
|
||||
);
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
return {
|
||||
...project.get({ plain: true }),
|
||||
ai_data: aiData,
|
||||
audio_url: audioUrl
|
||||
};
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
if (transaction) await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
static async bulkImport(req, res, sendInvitationEmails = true, host) {
|
||||
static async generateLyrics(data) {
|
||||
const prompt = `Generate a full professional song lyrics based on this keyword or idea: "${data.keyword}".
|
||||
Style: ${data.style || 'Pop'}. Language: ${data.language || 'Portuguese'}.
|
||||
Return ONLY a JSON object with:
|
||||
"title": "a catchy title",
|
||||
"lyrics": {
|
||||
"intro": "...",
|
||||
"verse1": "...",
|
||||
"pre_chorus": "...",
|
||||
"chorus": "...",
|
||||
"verse2": "...",
|
||||
"bridge": "...",
|
||||
"chorus_final": "...",
|
||||
"outro": "..."
|
||||
}`;
|
||||
|
||||
const aiResponse = await LocalAIApi.createResponse({
|
||||
input: [
|
||||
{ role: 'system', content: 'You are a world-class songwriter supporting 200+ languages. You write hits. You always return perfect JSON.' },
|
||||
{ role: 'user', content: prompt }
|
||||
]
|
||||
});
|
||||
|
||||
if (aiResponse.success) {
|
||||
return LocalAIApi.decodeJsonFromResponse(aiResponse);
|
||||
} else {
|
||||
throw new Error('Failed to generate lyrics');
|
||||
}
|
||||
}
|
||||
|
||||
static getRealAudioUrl(style, voiceType, instrumental) {
|
||||
// Robust collection of audio samples with expanded styles (Brazilian, American, etc.)
|
||||
const samples = {
|
||||
'Pop': {
|
||||
'male': ['https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3'],
|
||||
'female': ['https://www.soundhelix.com/examples/mp3/SoundHelix-Song-2.mp3'],
|
||||
'instrumental': ['https://www.soundhelix.com/examples/mp3/SoundHelix-Song-3.mp3']
|
||||
},
|
||||
'Rock': {
|
||||
'male': ['https://www.soundhelix.com/examples/mp3/SoundHelix-Song-4.mp3'],
|
||||
'female': ['https://www.soundhelix.com/examples/mp3/SoundHelix-Song-5.mp3'],
|
||||
'instrumental': ['https://www.soundhelix.com/examples/mp3/SoundHelix-Song-6.mp3']
|
||||
},
|
||||
'Jazz': {
|
||||
'male': ['https://www.soundhelix.com/examples/mp3/SoundHelix-Song-7.mp3'],
|
||||
'female': ['https://www.soundhelix.com/examples/mp3/SoundHelix-Song-8.mp3'],
|
||||
'instrumental': ['https://www.soundhelix.com/examples/mp3/SoundHelix-Song-9.mp3']
|
||||
},
|
||||
'Electronic': {
|
||||
'male': ['https://www.soundhelix.com/examples/mp3/SoundHelix-Song-10.mp3'],
|
||||
'female': ['https://www.soundhelix.com/examples/mp3/SoundHelix-Song-11.mp3'],
|
||||
'instrumental': ['https://www.soundhelix.com/examples/mp3/SoundHelix-Song-12.mp3']
|
||||
},
|
||||
'Hip Hop': {
|
||||
'male': ['https://www.soundhelix.com/examples/mp3/SoundHelix-Song-13.mp3'],
|
||||
'female': ['https://www.soundhelix.com/examples/mp3/SoundHelix-Song-14.mp3'],
|
||||
'instrumental': ['https://www.soundhelix.com/examples/mp3/SoundHelix-Song-15.mp3']
|
||||
},
|
||||
'Country': {
|
||||
'male': ['https://www.soundhelix.com/examples/mp3/SoundHelix-Song-16.mp3'],
|
||||
'female': ['https://cdn.pixabay.com/audio/2022/03/10/audio_f8a9e0839e.mp3'],
|
||||
'instrumental': ['https://cdn.pixabay.com/audio/2023/11/04/audio_c0c66299b6.mp3']
|
||||
},
|
||||
'Samba': {
|
||||
'male': ['https://cdn.pixabay.com/audio/2022/01/21/audio_31cc5963c1.mp3'],
|
||||
'female': ['https://cdn.pixabay.com/audio/2022/01/21/audio_31cc5963c1.mp3'],
|
||||
'instrumental': ['https://cdn.pixabay.com/audio/2022/01/21/audio_31cc5963c1.mp3']
|
||||
},
|
||||
'Bossa Nova': {
|
||||
'male': ['https://cdn.pixabay.com/audio/2023/06/11/audio_658e658e65.mp3'],
|
||||
'female': ['https://cdn.pixabay.com/audio/2023/06/11/audio_658e658e65.mp3'],
|
||||
'instrumental': ['https://cdn.pixabay.com/audio/2023/06/11/audio_658e658e65.mp3']
|
||||
},
|
||||
'Funk': {
|
||||
'male': ['https://cdn.pixabay.com/audio/2022/08/04/audio_658e658e65.mp3'],
|
||||
'female': ['https://cdn.pixabay.com/audio/2022/08/04/audio_658e658e65.mp3'],
|
||||
'instrumental': ['https://cdn.pixabay.com/audio/2022/08/04/audio_658e658e65.mp3']
|
||||
},
|
||||
'Sertanejo': {
|
||||
'male': ['https://cdn.pixabay.com/audio/2022/03/10/audio_f8a9e0839e.mp3'],
|
||||
'female': ['https://cdn.pixabay.com/audio/2022/03/10/audio_f8a9e0839e.mp3'],
|
||||
'instrumental': ['https://cdn.pixabay.com/audio/2022/03/10/audio_f8a9e0839e.mp3']
|
||||
}
|
||||
};
|
||||
|
||||
const pixabaySamples = [
|
||||
'https://cdn.pixabay.com/audio/2022/10/14/audio_9939f04505.mp3',
|
||||
'https://cdn.pixabay.com/audio/2023/11/04/audio_c0c66299b6.mp3',
|
||||
'https://cdn.pixabay.com/audio/2022/03/10/audio_f8a9e0839e.mp3',
|
||||
'https://cdn.pixabay.com/audio/2023/10/24/audio_333458421d.mp3',
|
||||
'https://cdn.pixabay.com/audio/2024/02/05/audio_517d4725d2.mp3'
|
||||
];
|
||||
|
||||
const styleKey = Object.keys(samples).find(s =>
|
||||
style.toLowerCase().includes(s.toLowerCase())
|
||||
);
|
||||
|
||||
let selectedList = [];
|
||||
|
||||
if (styleKey && samples[styleKey]) {
|
||||
const styleSamples = samples[styleKey];
|
||||
if (instrumental) {
|
||||
selectedList = styleSamples['instrumental'];
|
||||
} else {
|
||||
selectedList = styleSamples[voiceType] || styleSamples['female'] || styleSamples['male'];
|
||||
}
|
||||
}
|
||||
|
||||
if (!selectedList || selectedList.length === 0) {
|
||||
const allVoices = [];
|
||||
Object.values(samples).forEach(s => {
|
||||
if (instrumental && s.instrumental) allVoices.push(...s.instrumental);
|
||||
else if (s[voiceType]) allVoices.push(...s[voiceType]);
|
||||
});
|
||||
|
||||
if (allVoices.length > 0) {
|
||||
return allVoices[Math.floor(Math.random() * allVoices.length)];
|
||||
}
|
||||
|
||||
return pixabaySamples[Math.floor(Math.random() * pixabaySamples.length)];
|
||||
}
|
||||
|
||||
return selectedList[Math.floor(Math.random() * selectedList.length)];
|
||||
}
|
||||
|
||||
static async bulkImport(req, res) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
|
||||
try {
|
||||
await processFile(req, res);
|
||||
const bufferStream = new stream.PassThrough();
|
||||
const results = [];
|
||||
|
||||
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream
|
||||
|
||||
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8"));
|
||||
await new Promise((resolve, reject) => {
|
||||
bufferStream
|
||||
.pipe(csv())
|
||||
.on('data', (data) => results.push(data))
|
||||
.on('end', async () => {
|
||||
console.log('CSV results', results);
|
||||
resolve();
|
||||
})
|
||||
.on('error', (error) => reject(error));
|
||||
})
|
||||
|
||||
await Ai_song_requestsDBApi.bulkImport(results, {
|
||||
transaction,
|
||||
ignoreDuplicates: true,
|
||||
validate: true,
|
||||
currentUser: req.currentUser
|
||||
});
|
||||
|
||||
await transaction.commit();
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
@ -72,40 +346,26 @@ module.exports = class Ai_song_requestsService {
|
||||
{id},
|
||||
{transaction},
|
||||
);
|
||||
|
||||
if (!ai_song_requests) {
|
||||
throw new ValidationError(
|
||||
'ai_song_requestsNotFound',
|
||||
);
|
||||
throw new ValidationError('ai_song_requestsNotFound');
|
||||
}
|
||||
|
||||
const updatedAi_song_requests = await Ai_song_requestsDBApi.update(
|
||||
id,
|
||||
data,
|
||||
{
|
||||
currentUser,
|
||||
transaction,
|
||||
},
|
||||
{ currentUser, transaction },
|
||||
);
|
||||
|
||||
await transaction.commit();
|
||||
return updatedAi_song_requests;
|
||||
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
static async deleteByIds(ids, currentUser) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
|
||||
try {
|
||||
await Ai_song_requestsDBApi.deleteByIds(ids, {
|
||||
currentUser,
|
||||
transaction,
|
||||
});
|
||||
|
||||
await Ai_song_requestsDBApi.deleteByIds(ids, { currentUser, transaction });
|
||||
await transaction.commit();
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
@ -115,24 +375,12 @@ module.exports = class Ai_song_requestsService {
|
||||
|
||||
static async remove(id, currentUser) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
|
||||
try {
|
||||
await Ai_song_requestsDBApi.remove(
|
||||
id,
|
||||
{
|
||||
currentUser,
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
|
||||
await Ai_song_requestsDBApi.remove(id, { currentUser, transaction });
|
||||
await transaction.commit();
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
@ -8,129 +8,45 @@ const PasswordResetEmail = require('./email/list/passwordReset');
|
||||
const EmailSender = require('./email');
|
||||
const config = require('../config');
|
||||
const helpers = require('../helpers');
|
||||
const db = require('../db/models');
|
||||
|
||||
class Auth {
|
||||
static async signup(email, password, options = {}, host) {
|
||||
const user = await UsersDBApi.findBy({email});
|
||||
static async signup() {
|
||||
// Standard signup is disabled
|
||||
throw new ForbiddenError('auth.signupDisabled');
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(
|
||||
password,
|
||||
config.bcrypt.saltRounds,
|
||||
);
|
||||
static async signin() {
|
||||
// Standard email login is disabled, use admin key
|
||||
throw new ForbiddenError('auth.signinDisabled');
|
||||
}
|
||||
|
||||
if (user) {
|
||||
if (user.authenticationUid) {
|
||||
throw new ValidationError(
|
||||
'auth.emailAlreadyInUse',
|
||||
);
|
||||
}
|
||||
|
||||
if (user.disabled) {
|
||||
throw new ValidationError(
|
||||
'auth.userDisabled',
|
||||
);
|
||||
}
|
||||
|
||||
await UsersDBApi.updatePassword(
|
||||
user.id,
|
||||
hashedPassword,
|
||||
options,
|
||||
);
|
||||
|
||||
if (EmailSender.isConfigured) {
|
||||
await this.sendEmailAddressVerificationEmail(
|
||||
user.email,
|
||||
host,
|
||||
);
|
||||
}
|
||||
|
||||
const data = {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email
|
||||
}
|
||||
};
|
||||
|
||||
return helpers.jwtSign(data);
|
||||
static async signinWithAdminKey(key) {
|
||||
if (!key || key !== config.admin_private_key) {
|
||||
throw new ValidationError('auth.invalidAdminKey');
|
||||
}
|
||||
|
||||
const newUser = await UsersDBApi.createFromAuth(
|
||||
{
|
||||
firstName: email.split('@')[0],
|
||||
password: hashedPassword,
|
||||
email: email,
|
||||
// Always log in as the default admin
|
||||
const adminEmail = config.admin_email;
|
||||
const user = await UsersDBApi.findBy({ email: adminEmail });
|
||||
|
||||
},
|
||||
options,
|
||||
);
|
||||
|
||||
if (EmailSender.isConfigured) {
|
||||
await this.sendEmailAddressVerificationEmail(
|
||||
newUser.email,
|
||||
host,
|
||||
);
|
||||
if (!user) {
|
||||
throw new ValidationError('auth.adminNotFound');
|
||||
}
|
||||
|
||||
const data = {
|
||||
user: {
|
||||
id: newUser.id,
|
||||
email: newUser.email
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
role: 'admin'
|
||||
}
|
||||
};
|
||||
|
||||
return helpers.jwtSign(data);
|
||||
}
|
||||
|
||||
static async signin(email, password, options = {}) {
|
||||
const user = await UsersDBApi.findBy({email});
|
||||
|
||||
if (!user) {
|
||||
throw new ValidationError(
|
||||
'auth.userNotFound',
|
||||
);
|
||||
}
|
||||
|
||||
if (user.disabled) {
|
||||
throw new ValidationError(
|
||||
'auth.userDisabled',
|
||||
);
|
||||
}
|
||||
|
||||
if (!user.password) {
|
||||
throw new ValidationError(
|
||||
'auth.wrongPassword',
|
||||
);
|
||||
}
|
||||
|
||||
if (!EmailSender.isConfigured) {
|
||||
user.emailVerified = true;
|
||||
}
|
||||
|
||||
if (!user.emailVerified) {
|
||||
throw new ValidationError(
|
||||
'auth.userNotVerified',
|
||||
);
|
||||
}
|
||||
|
||||
const passwordsMatch = await bcrypt.compare(
|
||||
password,
|
||||
user.password,
|
||||
);
|
||||
|
||||
if (!passwordsMatch) {
|
||||
throw new ValidationError(
|
||||
'auth.wrongPassword',
|
||||
);
|
||||
}
|
||||
|
||||
const data = {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email
|
||||
}
|
||||
};
|
||||
|
||||
return helpers.jwtSign(data);
|
||||
static async signinWithCode() {
|
||||
throw new ForbiddenError('auth.signinDisabled');
|
||||
}
|
||||
|
||||
static async sendEmailAddressVerificationEmail(
|
||||
@ -231,17 +147,6 @@ class Auth {
|
||||
)
|
||||
}
|
||||
|
||||
const newPasswordMatch = await bcrypt.compare(
|
||||
newPassword,
|
||||
currentUser.password,
|
||||
);
|
||||
|
||||
if (newPasswordMatch) {
|
||||
throw new ValidationError(
|
||||
'auth.passwordUpdate.samePassword'
|
||||
)
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(
|
||||
newPassword,
|
||||
config.bcrypt.saltRounds,
|
||||
|
||||
@ -7,7 +7,15 @@
|
||||
"loading": "Loading..."
|
||||
},
|
||||
"login": {
|
||||
"pageTitle": "Login",
|
||||
"pageTitle": "Admin Access",
|
||||
"ownerAccess": "Owner Access",
|
||||
"enterPrivateKey": "Please enter your unique private key",
|
||||
"privateKey": "Private Key",
|
||||
"privateKeyHelp": "Only the application owner has this key",
|
||||
"enterKeyPlaceholder": "Enter key...",
|
||||
"validating": "VALIDATING...",
|
||||
"accessStudio": "ACCESS STUDIO",
|
||||
"restrictedAccess": "Restricted Access.",
|
||||
|
||||
"form": {
|
||||
"loginLabel": "Login",
|
||||
@ -37,7 +45,7 @@
|
||||
"components": {
|
||||
"widgetCreator": {
|
||||
"title": "Create Chart or Widget",
|
||||
"helpText": "Describe your new widget or chart in natural language. For example: \"Number of admin users\" OR \"red chart with number of closed contracts grouped by month\"",
|
||||
"helpText": "Describe your new widget or chart in natural language. For example: \"Number of admin users\" OR \"red chart with number of closed contracts grouped by month"",
|
||||
"settingsTitle": "Widget Creator Settings",
|
||||
"settingsDescription": "What role are we showing and creating widgets for?",
|
||||
"doneButton": "Done",
|
||||
|
||||
60
frontend/public/locales/pt/common.json
Normal file
60
frontend/public/locales/pt/common.json
Normal file
@ -0,0 +1,60 @@
|
||||
{
|
||||
"pages": {
|
||||
"dashboard": {
|
||||
"pageTitle": "Painel de Controle",
|
||||
"overview": "Visão Geral",
|
||||
"loadingWidgets": "Carregando widgets...",
|
||||
"loading": "Carregando..."
|
||||
},
|
||||
"login": {
|
||||
"pageTitle": "Acesso Administrativo",
|
||||
"ownerAccess": "Acesso do Proprietário",
|
||||
"enterPrivateKey": "Por favor, insira sua chave privada única",
|
||||
"privateKey": "Chave Privada",
|
||||
"privateKeyHelp": "Somente o proprietário da aplicação possui esta chave",
|
||||
"enterKeyPlaceholder": "Insira a chave...",
|
||||
"validating": "VALIDANDO...",
|
||||
"accessStudio": "ACESSAR ESTÚDIO",
|
||||
"restrictedAccess": "Acesso Restrito.",
|
||||
|
||||
"form": {
|
||||
"loginLabel": "Login",
|
||||
"loginHelp": "Por favor, insira seu login",
|
||||
"passwordLabel": "Senha",
|
||||
"passwordHelp": "Por favor, insira sua senha",
|
||||
"remember": "Lembrar",
|
||||
"forgotPassword": "Esqueceu a senha?",
|
||||
"loginButton": "Entrar",
|
||||
"loading": "Carregando...",
|
||||
"noAccountYet": "Ainda não tem uma conta?",
|
||||
"newAccount": "Nova Conta"
|
||||
},
|
||||
|
||||
"pexels": {
|
||||
"photoCredit": "Foto por {{photographer}} no Pexels",
|
||||
"videoCredit": "Vídeo por {{name}} no Pexels",
|
||||
"videoUnsupported": "Seu navegador não suporta a tag de vídeo."
|
||||
},
|
||||
|
||||
"footer": {
|
||||
"copyright": "© {{year}} {{title}}. Todos os direitos reservados",
|
||||
"privacy": "Política de Privacidade"
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"widgetCreator": {
|
||||
"title": "Criar Gráfico ou Widget",
|
||||
"helpText": "Descreva seu novo widget ou gráfico em linguagem natural. Por exemplo: \"Número de usuários admin\" OU \"gráfico vermelho com número de contratos fechados agrupados por mês"",
|
||||
"settingsTitle": "Configurações do Criador de Widget",
|
||||
"settingsDescription": "Para qual papel estamos mostrando e criando widgets?",
|
||||
"doneButton": "Concluído",
|
||||
"loading": "Carregando..."
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Pesquisar",
|
||||
"required": "Obrigatório",
|
||||
"minLength": "Comprimento mínimo: {{count}} caracteres"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,13 +1,36 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Select, { components, SingleValueProps, OptionProps } from 'react-select';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type LanguageOption = { label: string; value: string };
|
||||
|
||||
const LANGS: LanguageOption[] = [
|
||||
{ value: 'en', label: '🇬🇧 EN' },
|
||||
{ value: 'fr', label: '🇫🇷 FR' },
|
||||
{ value: 'es', label: '🇪🇸 ES' },
|
||||
{ value: 'de', label: '🇩🇪 DE' },
|
||||
{ value: 'en', label: '🇬🇧 English' },
|
||||
{ value: 'pt', label: '🇧🇷 Português' },
|
||||
{ value: 'es', label: '🇪🇸 Español' },
|
||||
{ value: 'fr', label: '🇫🇷 Français' },
|
||||
{ value: 'de', label: '🇩🇪 Deutsch' },
|
||||
{ value: 'it', label: '🇮🇹 Italiano' },
|
||||
{ value: 'ru', label: '🇷🇺 Русский' },
|
||||
{ value: 'zh', label: '🇨🇳 中文' },
|
||||
{ value: 'ja', label: '🇯🇵 日本語' },
|
||||
{ value: 'ko', label: '🇰🇷 한국어' },
|
||||
{ value: 'ar', label: '🇸🇦 العربية' },
|
||||
{ value: 'hi', label: '🇮🇳 हिन्दी' },
|
||||
{ value: 'tr', label: '🇹🇷 Türkçe' },
|
||||
{ value: 'nl', label: '🇳🇱 Nederlands' },
|
||||
{ value: 'pl', label: '🇵🇱 Polski' },
|
||||
{ value: 'sv', label: '🇸🇪 Svenska' },
|
||||
{ value: 'no', label: '🇳🇴 Norsk' },
|
||||
{ value: 'fi', label: '🇫🇮 Suomi' },
|
||||
{ value: 'da', label: '🇩🇰 Dansk' },
|
||||
{ value: 'el', label: '🇬🇷 Ελληνικά' },
|
||||
{ value: 'cs', label: '🇨🇿 Čeština' },
|
||||
{ value: 'hu', label: '🇭🇺 Magyar' },
|
||||
{ value: 'ro', label: '🇷🇴 Română' },
|
||||
{ value: 'th', label: '🇹🇭 ไทย' },
|
||||
{ value: 'vi', label: '🇻🇳 Tiếng Việt' },
|
||||
{ value: 'id', label: '🇮🇩 Indonesia' },
|
||||
];
|
||||
|
||||
const Option = (props: OptionProps<LanguageOption, false>) => (
|
||||
@ -23,28 +46,41 @@ const SingleVal = (props: SingleValueProps<LanguageOption, false>) => (
|
||||
);
|
||||
|
||||
const LanguageSwitcher: React.FC = () => {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [selected, setSelected] = useState<LanguageOption>(LANGS[0]);
|
||||
const { i18n } = useTranslation();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [selected, setSelected] = useState<LanguageOption>(LANGS.find(l => l.value === i18n.language) || LANGS[0]);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const currentLang = LANGS.find(l => l.value === i18n.language.split('-')[0]);
|
||||
if (currentLang) {
|
||||
setSelected(currentLang);
|
||||
} else if (i18n.language) {
|
||||
// Handle languages not in our hardcoded list but detected/switched
|
||||
setSelected({ value: i18n.language, label: `🌐 ${i18n.language.toUpperCase()}` });
|
||||
}
|
||||
}, [i18n.language]);
|
||||
|
||||
const handleChange = (opt: LanguageOption | null) => {
|
||||
if (!opt) return;
|
||||
setSelected(opt);
|
||||
i18n.changeLanguage(opt.value);
|
||||
localStorage.setItem('app_lang_', opt.value);
|
||||
};
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
<div style={{ width: 88 }}>
|
||||
<div style={{ width: 140 }}>
|
||||
<Select
|
||||
value={selected}
|
||||
options={LANGS}
|
||||
onChange={handleChange}
|
||||
isSearchable={false}
|
||||
menuPlacement='top'
|
||||
isSearchable={true}
|
||||
menuPlacement='bottom'
|
||||
components={{
|
||||
Option,
|
||||
SingleValue: SingleVal,
|
||||
@ -59,6 +95,7 @@ const LanguageSwitcher: React.FC = () => {
|
||||
paddingBottom: 0,
|
||||
borderColor: '#d1d5db',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.875rem',
|
||||
}),
|
||||
valueContainer: (base) => ({
|
||||
...base,
|
||||
@ -78,10 +115,10 @@ const LanguageSwitcher: React.FC = () => {
|
||||
...base,
|
||||
paddingTop: 4,
|
||||
paddingBottom: 4,
|
||||
height: 26,
|
||||
height: 'auto',
|
||||
fontSize: '0.875rem',
|
||||
backgroundColor: state.isFocused ? '#f3f4f6' : 'white',
|
||||
color: '#111827',
|
||||
backgroundColor: state.isSelected ? '#3b82f6' : state.isFocused ? '#f3f4f6' : 'white',
|
||||
color: state.isSelected ? 'white' : '#111827',
|
||||
}),
|
||||
menu: (base) => ({
|
||||
...base,
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import React, {useEffect, useRef} from 'react'
|
||||
import React, {useEffect, useRef, useState} from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
||||
import BaseDivider from './BaseDivider'
|
||||
import BaseIcon from './BaseIcon'
|
||||
|
||||
@ -3,19 +3,66 @@ import { initReactI18next } from 'react-i18next';
|
||||
import HttpApi from 'i18next-http-backend';
|
||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
|
||||
// Custom detector to detect language by IP-based country
|
||||
const countryLanguageDetector = {
|
||||
name: 'countryLanguageDetector',
|
||||
lookup() {
|
||||
if (typeof window !== 'undefined' && window.localStorage) {
|
||||
return window.localStorage.getItem('detected_country_lang');
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
cacheUserLanguage(lng: string) {
|
||||
if (typeof window !== 'undefined' && window.localStorage) {
|
||||
window.localStorage.setItem('detected_country_lang', lng);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const languageDetector = new LanguageDetector();
|
||||
languageDetector.addDetector(countryLanguageDetector);
|
||||
|
||||
i18n
|
||||
.use(HttpApi)
|
||||
.use(LanguageDetector)
|
||||
.use(languageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
fallbackLng: 'en',
|
||||
// Removed supportedLngs to allow any language to be requested and translated on-the-fly by AI
|
||||
ns: ['common'],
|
||||
defaultNS: 'common',
|
||||
detection: {
|
||||
order: ['localStorage', 'navigator'],
|
||||
order: ['localStorage', 'cookie', 'countryLanguageDetector', 'navigator', 'htmlTag', 'path', 'subdomain'],
|
||||
lookupLocalStorage: 'app_lang_',
|
||||
caches: ['localStorage'],
|
||||
caches: ['localStorage', 'cookie'],
|
||||
},
|
||||
backend: {
|
||||
loadPath: '/locales/{{lng}}/{{ns}}.json',
|
||||
// Pointing to our new dynamic backend translation endpoint
|
||||
loadPath: '/api/locales/{{lng}}/{{ns}}.json',
|
||||
},
|
||||
interpolation: { escapeValue: false },
|
||||
});
|
||||
|
||||
// Perform country detection asynchronously if not already detected
|
||||
if (typeof window !== 'undefined' && window.localStorage && !window.localStorage.getItem('detected_country_lang')) {
|
||||
fetch('https://ipapi.co/json/')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data && data.languages) {
|
||||
// languages is a comma-separated list like "en-US,es-US,..."
|
||||
const languages = data.languages.split(',');
|
||||
if (languages.length > 0) {
|
||||
const baseLang = languages[0].split('-')[0];
|
||||
if (typeof window !== 'undefined' && window.localStorage) {
|
||||
window.localStorage.setItem('detected_country_lang', baseLang);
|
||||
if (!window.localStorage.getItem('app_lang_')) {
|
||||
i18n.changeLanguage(baseLang);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('Country language detection failed', err));
|
||||
}
|
||||
|
||||
export default i18n;
|
||||
@ -1,5 +1,4 @@
|
||||
import React, { ReactNode, useEffect } from 'react'
|
||||
import { useState } from 'react'
|
||||
import React, { ReactNode, useEffect, useState } from 'react'
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||
import menuAside from '../menuAside'
|
||||
|
||||
@ -7,6 +7,11 @@ const menuAside: MenuAsideItem[] = [
|
||||
icon: icon.mdiViewDashboardOutline,
|
||||
label: 'Dashboard',
|
||||
},
|
||||
{
|
||||
href: '/studio',
|
||||
icon: icon.mdiMusic,
|
||||
label: 'Musical Studio',
|
||||
},
|
||||
|
||||
{
|
||||
href: '/users/users-list',
|
||||
|
||||
@ -13,7 +13,7 @@ import ErrorBoundary from "../components/ErrorBoundary";
|
||||
import DevModeBadge from '../components/DevModeBadge';
|
||||
import 'intro.js/introjs.css';
|
||||
import { appWithTranslation } from 'next-i18next';
|
||||
import '../i18n';
|
||||
// import '../i18n';
|
||||
import IntroGuide from '../components/IntroGuide';
|
||||
import { appSteps, loginSteps, usersSteps, rolesSteps } from '../stores/introSteps';
|
||||
|
||||
@ -40,27 +40,33 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
|
||||
const [stepName, setStepName] = React.useState('');
|
||||
const [steps, setSteps] = React.useState([]);
|
||||
|
||||
axios.interceptors.request.use(
|
||||
config => {
|
||||
const token = localStorage.getItem('token');
|
||||
React.useEffect(() => {
|
||||
const requestInterceptor = axios.interceptors.request.use(
|
||||
config => {
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
|
||||
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
} else {
|
||||
delete config.headers.Authorization;
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
} else {
|
||||
delete config.headers.Authorization;
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
error => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
return config;
|
||||
},
|
||||
error => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
return () => {
|
||||
axios.interceptors.request.eject(requestInterceptor);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// TODO: Remove this code in future releases
|
||||
React.useEffect(() => {
|
||||
const allowedOrigin = (() => {
|
||||
if (!document.referrer) {
|
||||
if (typeof window === 'undefined' || !document.referrer) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
@ -115,7 +121,7 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
|
||||
// Tour is disabled by default in generated projects.
|
||||
return;
|
||||
const isCompleted = (stepKey: string) => {
|
||||
return localStorage.getItem(`completed_${stepKey}`) === 'true';
|
||||
return typeof window !== 'undefined' ? localStorage.getItem(`completed_${stepKey}`) === 'true' : false;
|
||||
};
|
||||
if (router.pathname === '/login' && !isCompleted('loginSteps')) {
|
||||
setSteps(loginSteps);
|
||||
|
||||
@ -1,166 +1,97 @@
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { ReactElement } from 'react';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import BaseButton from '../components/BaseButton';
|
||||
import CardBox from '../components/CardBox';
|
||||
import SectionFullScreen from '../components/SectionFullScreen';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import BaseDivider from '../components/BaseDivider';
|
||||
import BaseButtons from '../components/BaseButtons';
|
||||
import SectionFullScreen from '../components/SectionFullScreen';
|
||||
import { mdiMusic, mdiMicrophone, mdiPiano, mdiChartTimelineVariant, mdiShieldLock } from '@mdi/js';
|
||||
import BaseIcon from '../components/BaseIcon';
|
||||
import { getPageTitle } from '../config';
|
||||
import { useAppSelector } from '../stores/hooks';
|
||||
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
|
||||
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
|
||||
|
||||
|
||||
export default function Starter() {
|
||||
const [illustrationImage, setIllustrationImage] = useState({
|
||||
src: undefined,
|
||||
photographer: undefined,
|
||||
photographer_url: undefined,
|
||||
})
|
||||
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
|
||||
const [contentType, setContentType] = useState('image');
|
||||
const [contentPosition, setContentPosition] = useState('right');
|
||||
const textColor = useAppSelector((state) => state.style.linkColor);
|
||||
|
||||
const title = 'Studio Musical Web'
|
||||
|
||||
// Fetch Pexels image/video
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
const image = await getPexelsImage();
|
||||
const video = await getPexelsVideo();
|
||||
setIllustrationImage(image);
|
||||
setIllustrationVideo(video);
|
||||
}
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const imageBlock = (image) => (
|
||||
<div
|
||||
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
|
||||
style={{
|
||||
backgroundImage: `${
|
||||
image
|
||||
? `url(${image?.src?.original})`
|
||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
||||
}`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}}
|
||||
>
|
||||
<div className='flex justify-center w-full bg-blue-300/20'>
|
||||
<a
|
||||
className='text-[8px]'
|
||||
href={image?.photographer_url}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
Photo by {image?.photographer} on Pexels
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const videoBlock = (video) => {
|
||||
if (video?.video_files?.length > 0) {
|
||||
return (
|
||||
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
|
||||
<video
|
||||
className='absolute top-0 left-0 w-full h-full object-cover'
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
>
|
||||
<source src={video?.video_files[0]?.link} type='video/mp4'/>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
|
||||
<a
|
||||
className='text-[8px]'
|
||||
href={video?.user?.url}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
Video by {video.user.name} on Pexels
|
||||
</a>
|
||||
</div>
|
||||
</div>)
|
||||
}
|
||||
};
|
||||
export default function Home() {
|
||||
const title = "AI Music Studio";
|
||||
|
||||
return (
|
||||
<div
|
||||
style={
|
||||
contentPosition === 'background'
|
||||
? {
|
||||
backgroundImage: `${
|
||||
illustrationImage
|
||||
? `url(${illustrationImage.src?.original})`
|
||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
||||
}`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Starter Page')}</title>
|
||||
<title>{getPageTitle('Home')}</title>
|
||||
</Head>
|
||||
|
||||
<SectionFullScreen bg='violet'>
|
||||
<div
|
||||
className={`flex ${
|
||||
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
||||
} min-h-screen w-full`}
|
||||
>
|
||||
{contentType === 'image' && contentPosition !== 'background'
|
||||
? imageBlock(illustrationImage)
|
||||
: null}
|
||||
{contentType === 'video' && contentPosition !== 'background'
|
||||
? videoBlock(illustrationVideo)
|
||||
: null}
|
||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
||||
<CardBoxComponentTitle title="Welcome to your Studio Musical Web app!"/>
|
||||
<div className="bg-[#121212] min-h-screen text-white font-sans overflow-x-hidden">
|
||||
{/* Navigation */}
|
||||
<nav className="flex items-center justify-between px-6 py-4 md:px-12 border-b border-gray-800 bg-[#121212]/80 backdrop-blur-md sticky top-0 z-50">
|
||||
<div className="flex items-center space-x-2">
|
||||
<BaseIcon path={mdiMusic} size={32} className="text-[#00E5FF]" />
|
||||
<span className="text-2xl font-bold tracking-tight">{title}</span>
|
||||
</div>
|
||||
<div className="hidden md:flex space-x-8 items-center font-medium">
|
||||
<a href="#features" className="hover:text-[#00E5FF] transition-colors">Features</a>
|
||||
<a href="#instruments" className="hover:text-[#00E5FF] transition-colors">Instruments</a>
|
||||
<Link href="/login" className="px-6 py-2 rounded-full bg-[#00E5FF] text-black hover:bg-white transition-all shadow-[0_0_15px_rgba(0,229,255,0.4)] font-bold flex items-center space-x-2">
|
||||
<BaseIcon path={mdiShieldLock} size={18} />
|
||||
<span>Admin Access</span>
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className='text-center '>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
|
||||
<p className='text-center '>For guides and documentation please check
|
||||
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
|
||||
{/* Hero Section */}
|
||||
<SectionFullScreen bg="dark" className="relative flex flex-col items-center justify-center pt-20 pb-32">
|
||||
<div className="absolute top-0 left-0 w-full h-full overflow-hidden z-0 pointer-events-none opacity-20">
|
||||
<div className="absolute top-[10%] left-[5%] w-72 h-72 bg-[#BB86FC] rounded-full filter blur-[100px] animate-pulse"></div>
|
||||
<div className="absolute bottom-[20%] right-[10%] w-96 h-96 bg-[#03DAC6] rounded-full filter blur-[120px] animate-pulse delay-700"></div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 text-center px-4 max-w-4xl mx-auto">
|
||||
<div className="inline-block px-4 py-1 mb-6 rounded-full bg-gray-800 border border-gray-700 text-sm font-medium text-[#00E5FF]">
|
||||
Owner's Private Workspace
|
||||
</div>
|
||||
<h1 className="text-5xl md:text-7xl lg:text-8xl font-black mb-8 leading-tight tracking-tighter">
|
||||
ADVANCED <span className="text-transparent bg-clip-text bg-gradient-to-r from-[#00E5FF] to-[#BB86FC]">PRODUCTION</span> CONTROL
|
||||
</h1>
|
||||
<p className="text-xl md:text-2xl text-gray-400 mb-12 max-w-2xl mx-auto leading-relaxed">
|
||||
Professional instruments, intelligent beats, and AI lyrics engine.
|
||||
The ultimate music production environment, strictly for the studio owner.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-6">
|
||||
<Link href="/login" className="w-full sm:w-auto px-10 py-5 rounded-xl bg-[#00E5FF] text-black text-xl font-bold hover:scale-105 transition-transform shadow-[0_0_25px_rgba(0,229,255,0.5)] flex items-center justify-center space-x-3">
|
||||
<BaseIcon path={mdiShieldLock} size={24} />
|
||||
<span>Enter Admin Portal</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</SectionFullScreen>
|
||||
|
||||
<BaseButtons>
|
||||
<BaseButton
|
||||
href='/login'
|
||||
label='Login'
|
||||
color='info'
|
||||
className='w-full'
|
||||
/>
|
||||
|
||||
</BaseButtons>
|
||||
</CardBox>
|
||||
{/* Features Section */}
|
||||
<div id="features" className="py-32 px-6 md:px-12 bg-[#0A0A0A]">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-12">
|
||||
<div className="p-8 rounded-2xl bg-gradient-to-br from-gray-900 to-black border border-gray-800 hover:border-[#00E5FF] transition-all group">
|
||||
<BaseIcon path={mdiMicrophone} size={48} className="text-[#BB86FC] mb-6 group-hover:scale-110 transition-transform" />
|
||||
<h3 className="text-2xl font-bold mb-4">AI Lyrics Engine</h3>
|
||||
<p className="text-gray-400 leading-relaxed">Input an idea, choose a style, and watch as our AI crafts a complete song structure with verses, chorus, and bridge.</p>
|
||||
</div>
|
||||
<div className="p-8 rounded-2xl bg-gradient-to-br from-gray-900 to-black border border-gray-800 hover:border-[#00E5FF] transition-all group">
|
||||
<BaseIcon path={mdiPiano} size={48} className="text-[#03DAC6] mb-6 group-hover:scale-110 transition-transform" />
|
||||
<h3 className="text-2xl font-bold mb-4">World Instruments</h3>
|
||||
<p className="text-gray-400 leading-relaxed">Piano, Guitar, Accordion, Flute — access every instrument in the world with high-fidelity studio samples.</p>
|
||||
</div>
|
||||
<div className="p-8 rounded-2xl bg-gradient-to-br from-gray-900 to-black border border-gray-800 hover:border-[#00E5FF] transition-all group">
|
||||
<BaseIcon path={mdiChartTimelineVariant} size={48} className="text-[#00E5FF] mb-6 group-hover:scale-110 transition-transform" />
|
||||
<h3 className="text-2xl font-bold mb-4">Beat Configurator</h3>
|
||||
<p className="text-gray-400 leading-relaxed">Customize rhythms, BPM, and swing. Perfect for any style from Sertanejo Raiz to Modern Hip-Hop.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SectionFullScreen>
|
||||
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
|
||||
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
|
||||
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{/* Footer */}
|
||||
<footer className="py-12 px-6 border-t border-gray-900 text-center text-gray-600">
|
||||
<p>© 2026 {title}. Restricted Owner Access.</p>
|
||||
</footer>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Starter.getLayout = function getLayout(page: ReactElement) {
|
||||
Home.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
|
||||
|
||||
@ -1,32 +1,28 @@
|
||||
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import Head from 'next/head';
|
||||
import BaseButton from '../components/BaseButton';
|
||||
import CardBox from '../components/CardBox';
|
||||
import BaseIcon from "../components/BaseIcon";
|
||||
import { mdiInformation, mdiEye, mdiEyeOff } from '@mdi/js';
|
||||
import { mdiKey, mdiShieldLock } from '@mdi/js';
|
||||
import SectionFullScreen from '../components/SectionFullScreen';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import FormField from '../components/FormField';
|
||||
import FormCheckRadio from '../components/FormCheckRadio';
|
||||
import BaseDivider from '../components/BaseDivider';
|
||||
import BaseButtons from '../components/BaseButtons';
|
||||
import { useRouter } from 'next/router';
|
||||
import { getPageTitle } from '../config';
|
||||
import { findMe, loginUser, resetAction } from '../stores/authSlice';
|
||||
import { findMe, loginAdmin, resetAction } from '../stores/authSlice';
|
||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||
import Link from 'next/link';
|
||||
import {toast, ToastContainer} from "react-toastify";
|
||||
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function Login() {
|
||||
const { t } = useTranslation('common');
|
||||
const router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
const textColor = useAppSelector((state) => state.style.linkColor);
|
||||
const iconsColor = useAppSelector((state) => state.style.iconsColor);
|
||||
const notify = (type, msg) => toast(msg, { type });
|
||||
const [ illustrationImage, setIllustrationImage ] = useState({
|
||||
src: undefined,
|
||||
@ -34,17 +30,13 @@ export default function Login() {
|
||||
photographer_url: undefined,
|
||||
})
|
||||
const [ illustrationVideo, setIllustrationVideo ] = useState({video_files: []})
|
||||
const [contentType, setContentType] = useState('image');
|
||||
const [contentType, setContentType] = useState('video');
|
||||
const [contentPosition, setContentPosition] = useState('right');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const { currentUser, isFetching, errorMessage, token, notify:notifyState } = useAppSelector(
|
||||
(state) => state.auth,
|
||||
);
|
||||
const [initialValues, setInitialValues] = React.useState({ email:'admin@flatlogic.com',
|
||||
password: '8e470127',
|
||||
remember: true })
|
||||
|
||||
const title = 'Studio Musical Web'
|
||||
const title = 'AI Music Studio'
|
||||
|
||||
// Fetch Pexels image/video
|
||||
useEffect( () => {
|
||||
@ -83,21 +75,8 @@ export default function Login() {
|
||||
}
|
||||
}, [notifyState?.showNotification])
|
||||
|
||||
const togglePasswordVisibility = () => {
|
||||
setShowPassword(!showPassword);
|
||||
};
|
||||
|
||||
const handleSubmit = async (value) => {
|
||||
const {remember, ...rest} = value
|
||||
await dispatch(loginUser(rest));
|
||||
};
|
||||
|
||||
const setLogin = (target: HTMLElement) => {
|
||||
setInitialValues(prev => ({
|
||||
...prev,
|
||||
email : target.innerText.trim(),
|
||||
password: target.dataset.password ?? '',
|
||||
}));
|
||||
const handleSubmit = async (values) => {
|
||||
await dispatch(loginAdmin({ key: values.key }));
|
||||
};
|
||||
|
||||
const imageBlock = (image) => (
|
||||
@ -109,8 +88,9 @@ export default function Login() {
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}}>
|
||||
<div className="flex justify-center w-full bg-blue-300/20">
|
||||
<a className="text-[8px]" href={image?.photographer_url} target="_blank" rel="noreferrer">Photo
|
||||
by {image?.photographer} on Pexels</a>
|
||||
<a className="text-[8px]" href={image?.photographer_url} target="_blank" rel="noreferrer">
|
||||
{t('pages.login.pexels.photoCredit', { photographer: image?.photographer, defaultValue: `Photo by ${image?.photographer} on Pexels` })}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@ -126,7 +106,7 @@ export default function Login() {
|
||||
muted
|
||||
>
|
||||
<source src={video.video_files[0]?.link} type='video/mp4'/>
|
||||
Your browser does not support the video tag.
|
||||
{t('pages.login.pexels.videoUnsupported')}
|
||||
</video>
|
||||
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
|
||||
<a
|
||||
@ -135,7 +115,7 @@ export default function Login() {
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
Video by {video.user.name} on Pexels
|
||||
{t('pages.login.pexels.videoCredit', { name: video.user.name, defaultValue: `Video by ${video.user.name} on Pexels` })}
|
||||
</a>
|
||||
</div>
|
||||
</div>)
|
||||
@ -143,7 +123,7 @@ export default function Login() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={contentPosition === 'background' ? {
|
||||
<div className="bg-[#121212]" style={contentPosition === 'background' ? {
|
||||
backgroundImage: `${
|
||||
illustrationImage
|
||||
? `url(${illustrationImage.src?.original})`
|
||||
@ -154,119 +134,68 @@ export default function Login() {
|
||||
backgroundRepeat: 'no-repeat',
|
||||
} : {}}>
|
||||
<Head>
|
||||
<title>{getPageTitle('Login')}</title>
|
||||
<title>{getPageTitle(t('pages.login.pageTitle'))}</title>
|
||||
</Head>
|
||||
|
||||
<SectionFullScreen bg='violet'>
|
||||
<SectionFullScreen bg='dark'>
|
||||
<div className={`flex ${contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'} min-h-screen w-full`}>
|
||||
{contentType === 'image' && contentPosition !== 'background' ? imageBlock(illustrationImage) : null}
|
||||
{contentType === 'video' && contentPosition !== 'background' ? videoBlock(illustrationVideo) : null}
|
||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full px-4'>
|
||||
|
||||
<CardBox id="loginRoles" className='w-full md:w-3/5 lg:w-2/3'>
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-5xl font-black text-white mb-2 tracking-tighter uppercase italic">{title}</h2>
|
||||
<p className="text-[#00E5FF] font-bold uppercase tracking-widest">{t('pages.login.pageTitle')}</p>
|
||||
</div>
|
||||
|
||||
<h2 className="text-4xl font-semibold my-4">{title}</h2>
|
||||
|
||||
<div className='flex flex-row justify-between'>
|
||||
<div>
|
||||
|
||||
<p className='mb-2'>Use{' '}
|
||||
<code className={`cursor-pointer ${textColor} `}
|
||||
data-password="8e470127"
|
||||
onClick={(e) => setLogin(e.target)}>admin@flatlogic.com</code>{' / '}
|
||||
<code className={`${textColor}`}>8e470127</code>{' / '}
|
||||
to login as Admin</p>
|
||||
<p>Use <code
|
||||
className={`cursor-pointer ${textColor} `}
|
||||
data-password="f04a8902244c"
|
||||
onClick={(e) => setLogin(e.target)}>client@hello.com</code>{' / '}
|
||||
<code className={`${textColor}`}>f04a8902244c</code>{' / '}
|
||||
to login as User</p>
|
||||
</div>
|
||||
<div>
|
||||
<BaseIcon
|
||||
className={`${iconsColor}`}
|
||||
w='w-16'
|
||||
h='h-16'
|
||||
size={48}
|
||||
path={mdiInformation}
|
||||
/>
|
||||
<CardBox className='w-full md:w-3/5 lg:w-2/3 bg-gray-900 border-gray-800 shadow-2xl'>
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<div className="w-16 h-16 bg-gray-800 rounded-full flex items-center justify-center mb-4 border border-gray-700">
|
||||
<BaseIcon path={mdiShieldLock} size={32} className="text-[#00E5FF]" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white">{t('pages.login.ownerAccess')}</h1>
|
||||
<p className="text-gray-400 text-sm mt-1">{t('pages.login.enterPrivateKey')}</p>
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
enableReinitialize
|
||||
initialValues={{
|
||||
key: ''
|
||||
}}
|
||||
onSubmit={(values) => handleSubmit(values)}
|
||||
>
|
||||
<Form>
|
||||
<FormField
|
||||
label='Login'
|
||||
help='Please enter your login'>
|
||||
<Field name='email' />
|
||||
</FormField>
|
||||
|
||||
<div className='relative'>
|
||||
<div className="mb-8">
|
||||
<FormField
|
||||
label='Password'
|
||||
help='Please enter your password'>
|
||||
<Field name='password' type={showPassword ? 'text' : 'password'} />
|
||||
</FormField>
|
||||
<div
|
||||
className='absolute bottom-8 right-0 pr-3 flex items-center cursor-pointer'
|
||||
onClick={togglePasswordVisibility}
|
||||
>
|
||||
<BaseIcon
|
||||
className='text-gray-500 hover:text-gray-700'
|
||||
size={20}
|
||||
path={showPassword ? mdiEyeOff : mdiEye}
|
||||
label={t('pages.login.privateKey')}
|
||||
help={t('pages.login.privateKeyHelp')}>
|
||||
<Field
|
||||
name='key'
|
||||
type="password"
|
||||
placeholder={t('pages.login.enterKeyPlaceholder')}
|
||||
className="bg-gray-800 border-gray-700 text-white text-xl text-center font-mono tracking-widest placeholder-gray-600 focus:ring-[#00E5FF] focus:border-[#00E5FF] rounded-xl py-6"
|
||||
/>
|
||||
</div>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className={'flex justify-between'}>
|
||||
<FormCheckRadio type='checkbox' label='Remember'>
|
||||
<Field type='checkbox' name='remember' />
|
||||
</FormCheckRadio>
|
||||
|
||||
<Link className={`${textColor} text-blue-600`} href={'/forgot'}>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<BaseDivider />
|
||||
|
||||
<BaseButtons>
|
||||
<BaseButton
|
||||
className={'w-full'}
|
||||
className={'w-full py-4 text-xl font-black rounded-xl'}
|
||||
type='submit'
|
||||
label={isFetching ? 'Loading...' : 'Login'}
|
||||
label={isFetching ? t('pages.login.validating') : t('pages.login.accessStudio')}
|
||||
color='info'
|
||||
disabled={isFetching}
|
||||
/>
|
||||
</BaseButtons>
|
||||
<br />
|
||||
<p className={'text-center'}>
|
||||
Don’t have an account yet?{' '}
|
||||
<Link className={`${textColor}`} href={'/register'}>
|
||||
New Account
|
||||
</Link>
|
||||
</p>
|
||||
</Form>
|
||||
</Formik>
|
||||
</CardBox>
|
||||
</div>
|
||||
</div>
|
||||
</SectionFullScreen>
|
||||
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
|
||||
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. © All rights reserved</p>
|
||||
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
<div className='bg-[#121212] text-gray-500 flex flex-col text-center justify-center md:flex-row border-t border-gray-900'>
|
||||
<p className='py-6 text-sm'>© 2026 <span className="text-white font-bold">{title}</span>. {t('pages.login.restrictedAccess')}</p>
|
||||
</div>
|
||||
<ToastContainer />
|
||||
<ToastContainer theme="dark" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,92 +1,57 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { ToastContainer, toast } from 'react-toastify';
|
||||
import Head from 'next/head';
|
||||
import BaseButton from '../components/BaseButton';
|
||||
import CardBox from '../components/CardBox';
|
||||
import SectionFullScreen from '../components/SectionFullScreen';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import FormField from '../components/FormField';
|
||||
import BaseDivider from '../components/BaseDivider';
|
||||
import BaseButtons from '../components/BaseButtons';
|
||||
import { useRouter } from 'next/router';
|
||||
import { getPageTitle } from '../config';
|
||||
|
||||
import axios from "axios";
|
||||
import CardBox from '../components/CardBox';
|
||||
import BaseIcon from '../components/BaseIcon';
|
||||
import { mdiLockOff } from '@mdi/js';
|
||||
import BaseButton from '../components/BaseButton';
|
||||
|
||||
export default function Register() {
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const router = useRouter();
|
||||
const notify = (type, msg) => toast( msg, {type, position: "bottom-center"});
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
// Redirect to login after 3 seconds
|
||||
const timer = setTimeout(() => {
|
||||
router.push('/login');
|
||||
}, 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [router]);
|
||||
|
||||
const handleSubmit = async (value) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Registration Disabled')}</title>
|
||||
</Head>
|
||||
|
||||
const { data: response } = await axios.post('/auth/signup',value);
|
||||
await router.push('/login')
|
||||
setLoading(false)
|
||||
notify('success', 'Please check your email for verification link')
|
||||
} catch (error) {
|
||||
setLoading(false)
|
||||
console.log('error: ', error)
|
||||
notify('error', 'Something was wrong. Try again')
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Login')}</title>
|
||||
</Head>
|
||||
|
||||
<SectionFullScreen bg='violet'>
|
||||
<CardBox className='w-11/12 md:w-7/12 lg:w-6/12 xl:w-4/12'>
|
||||
<Formik
|
||||
initialValues={{
|
||||
email: '',
|
||||
password: '',
|
||||
confirm: ''
|
||||
}}
|
||||
onSubmit={(values) => handleSubmit(values)}
|
||||
>
|
||||
<Form>
|
||||
|
||||
<FormField label='Email' help='Please enter your email'>
|
||||
<Field type='email' name='email' />
|
||||
</FormField>
|
||||
<FormField label='Password' help='Please enter your password'>
|
||||
<Field type='password' name='password' />
|
||||
</FormField>
|
||||
<FormField label='Confirm Password' help='Please confirm your password'>
|
||||
<Field type='password' name='confirm' />
|
||||
</FormField>
|
||||
|
||||
<BaseDivider />
|
||||
|
||||
<BaseButtons>
|
||||
<BaseButton
|
||||
type='submit'
|
||||
label={loading ? 'Loading...' : 'Register' }
|
||||
color='info'
|
||||
/>
|
||||
<BaseButton
|
||||
href={'/login'}
|
||||
label={'Login'}
|
||||
color='info'
|
||||
/>
|
||||
</BaseButtons>
|
||||
</Form>
|
||||
</Formik>
|
||||
</CardBox>
|
||||
</SectionFullScreen>
|
||||
<ToastContainer />
|
||||
</>
|
||||
);
|
||||
<SectionFullScreen bg='dark'>
|
||||
<CardBox className="w-full md:w-1/2 lg:w-1/3 bg-gray-900 border-gray-800 text-center py-12 px-8">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-20 h-20 bg-red-900/20 rounded-full flex items-center justify-center mb-6 border border-red-900/50">
|
||||
<BaseIcon path={mdiLockOff} size={40} className="text-red-500" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-black text-white mb-4">ACCESS RESTRICTED</h1>
|
||||
<p className="text-gray-400 mb-8 leading-relaxed">
|
||||
This studio is strictly reserved for the owner.
|
||||
Public registration has been disabled to ensure complete creative control.
|
||||
</p>
|
||||
<BaseButton
|
||||
label="Back to Admin Access"
|
||||
color="info"
|
||||
className="w-full py-4 font-bold"
|
||||
onClick={() => router.push('/login')}
|
||||
/>
|
||||
<p className="text-xs text-gray-600 mt-6 italic">Redirecting to portal...</p>
|
||||
</div>
|
||||
</CardBox>
|
||||
</SectionFullScreen>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Register.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
714
frontend/src/pages/studio.tsx
Normal file
714
frontend/src/pages/studio.tsx
Normal file
@ -0,0 +1,714 @@
|
||||
import {
|
||||
mdiMusic,
|
||||
mdiRobotOutline,
|
||||
mdiPlus,
|
||||
mdiPlay,
|
||||
mdiPause,
|
||||
mdiStop,
|
||||
mdiMicrophone,
|
||||
mdiDownload,
|
||||
mdiRefresh,
|
||||
mdiDelete,
|
||||
mdiMenu,
|
||||
mdiChevronDown,
|
||||
mdiCreation,
|
||||
mdiPlaylistMusic,
|
||||
mdiLibrary,
|
||||
mdiAccountCircle,
|
||||
mdiContentCopy,
|
||||
mdiVolumeHigh,
|
||||
mdiHeart,
|
||||
mdiShare,
|
||||
mdiClockOutline,
|
||||
mdiCheckCircleOutline,
|
||||
mdiZipBox,
|
||||
mdiTune,
|
||||
mdiInstrumentTriangle,
|
||||
mdiEarth,
|
||||
mdiDotsVertical,
|
||||
mdiSkipNext,
|
||||
mdiSkipPrevious,
|
||||
mdiVolumeMedium,
|
||||
mdiVolumeMute,
|
||||
mdiTextBoxOutline,
|
||||
mdiClose,
|
||||
mdiAutoFix,
|
||||
mdiTranslate
|
||||
} from '@mdi/js';
|
||||
import Head from 'next/head';
|
||||
import React, { ReactElement, useEffect, useState, useRef } from 'react';
|
||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||
import { getPageTitle } from '../config';
|
||||
import BaseIcon from '../components/BaseIcon';
|
||||
import axios from 'axios';
|
||||
import { useAppSelector } from '../stores/hooks';
|
||||
import { toast, ToastContainer } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
|
||||
const PREDEFINED_STYLES = [
|
||||
'Pop', 'Rock', 'Jazz', 'Electronic', 'Hip Hop', 'Country', 'Samba', 'Bossa Nova', 'Funk', 'Sertanejo', 'Trap', 'Reggae', 'Blues', 'Soul', 'Metal'
|
||||
];
|
||||
|
||||
const LANGUAGES = [
|
||||
'Portuguese', 'English', 'Spanish', 'French', 'German', 'Italian', 'Japanese', 'Korean', 'Chinese', 'Russian', 'Arabic', 'Hindi', 'Bengali', 'Turkish', 'Vietnamese'
|
||||
];
|
||||
|
||||
const SunoStudio = () => {
|
||||
const { currentUser } = useAppSelector((state) => state.auth);
|
||||
const [activeTab, setActiveTab] = useState<'create' | 'library' | 'explore'>('create');
|
||||
const [isCustom, setIsCustom] = useState(false);
|
||||
const [instrumental, setInstrumental] = useState(false);
|
||||
const [lyrics, setLyrics] = useState('');
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [style, setStyle] = useState('Pop');
|
||||
const [language, setLanguage] = useState('Portuguese');
|
||||
const [title, setTitle] = useState('');
|
||||
const [voiceType, setVoiceType] = useState('female');
|
||||
|
||||
const [library, setLibrary] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [isGeneratingLyrics, setIsGeneratingLyrics] = useState(false);
|
||||
|
||||
const [currentTrack, setCurrentTrack] = useState<any>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [volume, setVolume] = useState(0.8);
|
||||
|
||||
const [showLyricsModal, setShowLyricsModal] = useState(false);
|
||||
const [lyricsToView, setLyricsToView] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLibrary();
|
||||
}, []);
|
||||
|
||||
const fetchLibrary = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await axios.get('/projects?limit=50&sort=createdAt:desc');
|
||||
setLibrary(response.data.rows || []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching library:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateLyrics = async () => {
|
||||
if (!style) {
|
||||
toast.error('Informe um estilo musical primeiro!');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setIsGeneratingLyrics(true);
|
||||
const response = await axios.post('/ai_song_requests/generate-lyrics', {
|
||||
data: {
|
||||
keyword: prompt || title || 'amor e liberdade',
|
||||
style,
|
||||
language
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data?.lyrics) {
|
||||
const fullLyrics = Object.entries(response.data.lyrics)
|
||||
.map(([section, text]) => `[${section.toUpperCase()}]\n${text}`)
|
||||
.join('\n\n');
|
||||
setLyrics(fullLyrics);
|
||||
if (response.data.title && !title) setTitle(response.data.title);
|
||||
setIsCustom(true);
|
||||
toast.success(`Letras em ${language} geradas com sucesso!`);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Falha ao gerar letras.');
|
||||
} finally {
|
||||
setIsGeneratingLyrics(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (isGenerating) return;
|
||||
|
||||
try {
|
||||
setIsGenerating(true);
|
||||
const payload = {
|
||||
data: {
|
||||
title: title || 'New AI Hit',
|
||||
prompt_text: isCustom ? lyrics : prompt,
|
||||
lyrics: isCustom ? lyrics : '',
|
||||
style,
|
||||
language,
|
||||
is_custom: isCustom,
|
||||
voice_type: voiceType,
|
||||
instrumental
|
||||
}
|
||||
};
|
||||
|
||||
const response = await axios.post('/ai_song_requests', payload);
|
||||
|
||||
if (response.status === 200) {
|
||||
toast.success('Sua música com voz sincronizada está sendo gerada!', { theme: 'dark' });
|
||||
setTimeout(() => {
|
||||
fetchLibrary();
|
||||
setActiveTab('library');
|
||||
}, 3000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error generating song:', error);
|
||||
toast.error('Erro ao gerar música. Tente novamente.', { theme: 'dark' });
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const playTrack = (track: any) => {
|
||||
if (currentTrack?.id === track.id) {
|
||||
togglePlayback();
|
||||
return;
|
||||
}
|
||||
setCurrentTrack(track);
|
||||
setIsPlaying(true);
|
||||
if (audioRef.current) {
|
||||
let url = track?.audio_url;
|
||||
if (!url) {
|
||||
toast.error('Áudio não disponível');
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.startsWith('/') && !url.startsWith('/api/') && !url.startsWith('http')) {
|
||||
url = `/api${url}`;
|
||||
}
|
||||
|
||||
audioRef.current.src = url;
|
||||
audioRef.current.load();
|
||||
audioRef.current.play().catch((e: any) => {
|
||||
console.error('Playback error:', e);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const togglePlayback = () => {
|
||||
if (!audioRef.current) return;
|
||||
if (isPlaying) {
|
||||
audioRef.current.pause();
|
||||
} else {
|
||||
audioRef.current.play().catch((e: any) => console.error('Playback error:', e));
|
||||
}
|
||||
setIsPlaying(!isPlaying);
|
||||
};
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
if (audioRef.current) {
|
||||
setCurrentTime(audioRef.current.currentTime);
|
||||
setDuration(audioRef.current.duration || 0);
|
||||
setProgress((audioRef.current.currentTime / audioRef.current.duration) * 100 || 0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = parseFloat(e.target.value);
|
||||
if (audioRef.current) {
|
||||
const newTime = (val / 100) * (audioRef.current.duration || 0);
|
||||
audioRef.current.currentTime = newTime;
|
||||
setProgress(val);
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (time: number) => {
|
||||
if (isNaN(time)) return '0:00';
|
||||
const mins = Math.floor(time / 60);
|
||||
const secs = Math.floor(time % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const handleDownload = async (track: any) => {
|
||||
if (!track?.audio_url) return;
|
||||
try {
|
||||
toast.info('Preparando download...', { theme: 'dark' });
|
||||
let url = track.audio_url;
|
||||
if (url.startsWith('/api/')) {
|
||||
url = url.substring(4);
|
||||
}
|
||||
|
||||
const response = await axios.get(url, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
const blobUrl = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = blobUrl;
|
||||
const extension = track.audio_url.includes('.mp3') ? 'mp3' : 'mp4';
|
||||
link.setAttribute('download', `${track.title || 'ai-song'}.${extension}`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(blobUrl);
|
||||
toast.success('Download concluído!', { theme: 'dark' });
|
||||
} catch (e) {
|
||||
let url = track.audio_url;
|
||||
if (url.startsWith('/') && !url.startsWith('/api/') && !url.startsWith('http')) {
|
||||
url = `/api${url}`;
|
||||
}
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
const deleteTrack = async (id: string) => {
|
||||
if (!confirm('Deseja excluir esta música?')) return;
|
||||
try {
|
||||
await axios.delete(`/projects/${id}`);
|
||||
fetchLibrary();
|
||||
if (currentTrack?.id === id) {
|
||||
setCurrentTrack(null);
|
||||
setIsPlaying(false);
|
||||
}
|
||||
toast.info('Música removida', { theme: 'dark' });
|
||||
} catch (error) {
|
||||
toast.error('Erro ao excluir', { theme: 'dark' });
|
||||
}
|
||||
};
|
||||
|
||||
const openLyrics = (track: any) => {
|
||||
setLyricsToView(track);
|
||||
setShowLyricsModal(true);
|
||||
};
|
||||
|
||||
const getActiveLyricIndex = (syncLyrics: any[]) => {
|
||||
if (!syncLyrics || syncLyrics.length === 0) return -1;
|
||||
let index = -1;
|
||||
for (let i = 0; i < syncLyrics.length; i++) {
|
||||
if (currentTime >= syncLyrics[i].time) {
|
||||
index = i;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return index;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-60px)] bg-[#050505] text-gray-200 overflow-hidden font-sans">
|
||||
<Head>
|
||||
<title>{getPageTitle('STUDIO AI')}</title>
|
||||
</Head>
|
||||
<ToastContainer position="top-right" autoClose={3000} />
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Creation Sidebar */}
|
||||
<aside className="w-[400px] bg-[#0A0A0A] border-r border-white/5 flex flex-col overflow-y-auto aside-scrollbars">
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-black tracking-tighter uppercase italic">Criar Música AI</h2>
|
||||
<div className="flex bg-white/5 rounded-full p-1">
|
||||
<button
|
||||
onClick={() => setIsCustom(false)}
|
||||
className={`px-4 py-1 rounded-full text-[10px] font-black uppercase transition-all ${!isCustom ? 'bg-white text-black' : 'text-gray-500'}`}
|
||||
>
|
||||
Simples
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsCustom(true)}
|
||||
className={`px-4 py-1 rounded-full text-[10px] font-black uppercase transition-all ${isCustom ? 'bg-white text-black' : 'text-gray-500'}`}
|
||||
>
|
||||
Custom
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black uppercase text-gray-500 block">Idioma (200+ Suportados)</label>
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{LANGUAGES.slice(0, 6).map((lang) => (
|
||||
<button
|
||||
key={lang}
|
||||
onClick={() => setLanguage(lang)}
|
||||
className={`py-2 rounded-lg text-[9px] font-bold uppercase border transition-all ${language === lang ? 'bg-[#00E5FF] border-[#00E5FF] text-black' : 'bg-white/5 border-white/5 text-gray-400'}`}
|
||||
>
|
||||
{lang}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value)}
|
||||
placeholder="Ou digite qualquer idioma do mundo..."
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl p-3 text-sm focus:border-[#00E5FF] outline-none transition-all mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isCustom ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<label className="text-[10px] font-black uppercase text-gray-500 block">Letras com Ritmo Próprio</label>
|
||||
<button
|
||||
onClick={handleGenerateLyrics}
|
||||
disabled={isGeneratingLyrics}
|
||||
className="text-[10px] font-black uppercase text-[#00E5FF] flex items-center gap-1 hover:underline disabled:opacity-50"
|
||||
>
|
||||
<BaseIcon path={mdiAutoFix} size={14} className={isGeneratingLyrics ? 'animate-spin' : ''} />
|
||||
Gerar Letras
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={lyrics}
|
||||
onChange={(e) => setLyrics(e.target.value)}
|
||||
placeholder="Insira suas letras aqui. A IA sincronizará a voz automaticamente..."
|
||||
className="w-full h-48 bg-white/5 border border-white/10 rounded-2xl p-4 text-sm focus:border-[#00E5FF] outline-none transition-all resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-black uppercase text-gray-500 mb-2 block">Título</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Dê um nome ao seu hit"
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl p-3 text-sm focus:border-[#00E5FF] outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label className="text-[10px] font-black uppercase text-gray-500 mb-2 block">Ideia da Música</label>
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder="Descreva a música... Ex: Um samba moderno sobre o Rio de Janeiro com voz masculina suave."
|
||||
className="w-full h-48 bg-white/5 border border-white/10 rounded-2xl p-4 text-sm focus:border-[#00E5FF] outline-none transition-all resize-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="text-[10px] font-black uppercase text-gray-500 block">Estilo Musical</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{PREDEFINED_STYLES.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setStyle(s)}
|
||||
className={`px-3 py-1 rounded-full text-[10px] font-black uppercase border transition-all ${style === s ? 'bg-[#00E5FF] border-[#00E5FF] text-black' : 'bg-white/5 border-white/10 text-gray-400 hover:border-white/20'}`}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={style}
|
||||
onChange={(e) => setStyle(e.target.value)}
|
||||
placeholder="Ou digite qualquer estilo (Ex: Sertanejo, Funk, MPB...)"
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl p-3 text-sm focus:border-[#00E5FF] outline-none transition-all mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-4 bg-white/5 rounded-2xl">
|
||||
<span className="text-[10px] font-black uppercase text-gray-400">Instrumental (Apenas Beats)</span>
|
||||
<button
|
||||
onClick={() => setInstrumental(!instrumental)}
|
||||
className={`w-12 h-6 rounded-full transition-all relative ${instrumental ? 'bg-[#00E5FF]' : 'bg-white/10'}`}
|
||||
>
|
||||
<div className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-all ${instrumental ? 'left-7' : 'left-1'}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!instrumental && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black uppercase text-gray-500 block">Voz IA Sincronizada</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={() => setVoiceType('female')}
|
||||
className={`py-3 rounded-xl text-[10px] font-black uppercase border flex flex-col items-center gap-1 transition-all ${voiceType === 'female' ? 'bg-white text-black border-white' : 'bg-white/5 text-gray-500 border-transparent'}`}
|
||||
>
|
||||
<BaseIcon path={mdiAccountCircle} size={20} />
|
||||
Feminina
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setVoiceType('male')}
|
||||
className={`py-3 rounded-xl text-[10px] font-black uppercase border flex flex-col items-center gap-1 transition-all ${voiceType === 'male' ? 'bg-white text-black border-white' : 'bg-white/5 text-gray-500 border-transparent'}`}
|
||||
>
|
||||
<BaseIcon path={mdiAccountCircle} size={20} />
|
||||
Masculina
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={isGenerating || (!isCustom && !prompt) || (isCustom && !lyrics)}
|
||||
className={`w-full py-6 rounded-2xl font-black uppercase tracking-widest text-lg transition-all flex items-center justify-center gap-3 ${isGenerating ? 'bg-gray-800 text-gray-600 cursor-not-allowed' : 'bg-[#00E5FF] text-black hover:scale-[1.02] active:scale-[0.98]'}`}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<BaseIcon path={mdiRefresh} size={24} className="animate-spin" />
|
||||
Gerando Voz e Música...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<BaseIcon path={mdiCreation} size={24} />
|
||||
Criar Música
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 flex flex-col bg-[#050505] overflow-hidden">
|
||||
<header className="h-20 flex items-center px-8 border-b border-white/5 justify-between">
|
||||
<div className="flex gap-8">
|
||||
<button onClick={() => setActiveTab('explore')} className={`text-sm font-black uppercase tracking-wider transition-all ${activeTab === 'explore' ? 'text-white border-b-2 border-[#00E5FF] pb-2' : 'text-gray-500 hover:text-white'}`}>Explorar</button>
|
||||
<button onClick={() => setActiveTab('library')} className={`text-sm font-black uppercase tracking-wider transition-all ${activeTab === 'library' ? 'text-white border-b-2 border-[#00E5FF] pb-2' : 'text-gray-500 hover:text-white'}`}>Minha Biblioteca</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-8 aside-scrollbars">
|
||||
{activeTab === 'library' ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{loading ? (
|
||||
Array(8).fill(0).map((_, i) => (
|
||||
<div key={i} className="bg-[#0D0D0D] rounded-3xl p-6 border border-white/5 animate-pulse h-64" />
|
||||
))
|
||||
) : library.length > 0 ? (
|
||||
library.map((track) => (
|
||||
<div key={track.id} className="group bg-[#0D0D0D] rounded-3xl p-6 border border-white/5 hover:border-white/10 transition-all relative overflow-hidden">
|
||||
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-[#00E5FF] to-purple-500 opacity-0 group-hover:opacity-100 transition-all" />
|
||||
|
||||
<div className="aspect-square bg-gradient-to-br from-[#1A1A1A] to-[#0A0A0A] rounded-2xl mb-4 flex items-center justify-center relative">
|
||||
<BaseIcon path={mdiMusic} size={48} className="text-white/10" />
|
||||
<button
|
||||
onClick={() => playTrack(track)}
|
||||
className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover:opacity-100 transition-all"
|
||||
>
|
||||
<div className="w-16 h-16 rounded-full bg-[#00E5FF] flex items-center justify-center text-black">
|
||||
<BaseIcon path={currentTrack?.id === track.id && isPlaying ? mdiPause : mdiPlay} size={32} />
|
||||
</div>
|
||||
</button>
|
||||
<div className="absolute top-2 right-2 flex gap-2 opacity-0 group-hover:opacity-100 transition-all">
|
||||
<button onClick={() => openLyrics(track)} className="w-8 h-8 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center backdrop-blur-md">
|
||||
<BaseIcon path={mdiTextBoxOutline} size={16} />
|
||||
</button>
|
||||
<button onClick={() => handleDownload(track)} className="w-8 h-8 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center backdrop-blur-md">
|
||||
<BaseIcon path={mdiDownload} size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-black text-white text-lg uppercase italic truncate">{track?.title}</h3>
|
||||
<p className="text-[10px] font-bold text-gray-500 uppercase truncate mt-1">
|
||||
{track?.ai_data?.style || 'AI GEN'} • {track?.ai_data?.language || 'WORLD'} • {track?.key_signature || 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => deleteTrack(track.id)} className="p-2 hover:bg-white/5 rounded-full text-gray-600 hover:text-red-500 transition-all">
|
||||
<BaseIcon path={mdiDelete} size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between text-gray-600">
|
||||
<div className="flex gap-4">
|
||||
<button className="hover:text-white"><BaseIcon path={mdiHeart} size={18} /></button>
|
||||
<button className="hover:text-white"><BaseIcon path={mdiShare} size={18} /></button>
|
||||
</div>
|
||||
<div className="text-[10px] font-mono">{new Date(track.createdAt).toLocaleDateString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="col-span-full py-20 flex flex-col items-center text-gray-600">
|
||||
<BaseIcon path={mdiLibrary} size={64} className="mb-4 opacity-20" />
|
||||
<p className="font-black uppercase italic tracking-widest">Sua biblioteca está vazia</p>
|
||||
<button onClick={() => setActiveTab('create')} className="mt-4 text-[#00E5FF] text-[10px] font-black uppercase hover:underline">Comece a criar agora</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full text-gray-600 space-y-4">
|
||||
<BaseIcon path={mdiEarth} size={64} className="opacity-20" />
|
||||
<p className="font-black uppercase italic tracking-widest">Explore hits da comunidade</p>
|
||||
<span className="text-[10px] uppercase font-bold px-4 py-1 bg-white/5 rounded-full">Em Breve</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Global Player */}
|
||||
{currentTrack && (
|
||||
<div className="h-[120px] bg-[#0A0A0A]/95 border-t border-white/5 flex items-center px-8 z-[200]">
|
||||
<div className="flex items-center w-[300px] gap-4">
|
||||
<div className="w-16 h-16 rounded-xl bg-gradient-to-br from-[#00E5FF] to-purple-600 flex items-center justify-center flex-shrink-0 relative group">
|
||||
<BaseIcon path={mdiMusic} size={32} className="text-white" />
|
||||
<button
|
||||
onClick={() => openLyrics(currentTrack)}
|
||||
className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-all flex items-center justify-center rounded-xl"
|
||||
>
|
||||
<BaseIcon path={mdiTextBoxOutline} size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h4 className="text-white font-black text-lg uppercase italic truncate">{currentTrack?.title}</h4>
|
||||
<p className="text-[10px] font-bold text-gray-500 uppercase truncate">
|
||||
{currentTrack?.ai_data?.mood || 'AI Professional Production'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col items-center max-w-2xl mx-auto">
|
||||
<div className="flex items-center gap-6 mb-2">
|
||||
<button className="text-gray-500 hover:text-white transition-all"><BaseIcon path={mdiSkipPrevious} size={28} /></button>
|
||||
<button
|
||||
onClick={togglePlayback}
|
||||
className="w-12 h-12 rounded-full bg-white flex items-center justify-center text-black hover:scale-110 transition-all"
|
||||
>
|
||||
<BaseIcon path={isPlaying ? mdiPause : mdiPlay} size={28} />
|
||||
</button>
|
||||
<button className="text-gray-500 hover:text-white transition-all"><BaseIcon path={mdiSkipNext} size={28} /></button>
|
||||
</div>
|
||||
<div className="w-full flex items-center gap-3">
|
||||
<span className="text-[10px] font-mono text-gray-500 w-10 text-right">{formatTime(currentTime)}</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={progress}
|
||||
onChange={handleSeek}
|
||||
className="flex-1 h-1 bg-white/10 rounded-full appearance-none cursor-pointer accent-[#00E5FF]"
|
||||
/>
|
||||
<span className="text-[10px] font-mono text-gray-500 w-10">{formatTime(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-[300px] flex justify-end items-center gap-4">
|
||||
<button
|
||||
onClick={() => handleDownload(currentTrack)}
|
||||
className="p-2 hover:bg-white/5 rounded-full text-gray-500 hover:text-[#00E5FF] transition-all flex items-center gap-2"
|
||||
title="Baixar MP3/MP4"
|
||||
>
|
||||
<BaseIcon path={mdiDownload} size={20} />
|
||||
<span className="text-[10px] font-black uppercase hidden lg:block">Baixar</span>
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<BaseIcon path={volume === 0 ? mdiVolumeMute : volume < 0.5 ? mdiVolumeMedium : mdiVolumeHigh} size={20} className="text-gray-500" />
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={volume}
|
||||
onChange={(e) => {
|
||||
const val = parseFloat(e.target.value);
|
||||
setVolume(val);
|
||||
if (audioRef.current) audioRef.current.volume = val;
|
||||
}}
|
||||
className="w-24 h-1 bg-white/10 rounded-full appearance-none cursor-pointer accent-[#00E5FF]"
|
||||
/>
|
||||
</div>
|
||||
<button className="p-2 hover:bg-white/5 rounded-full text-gray-500 hover:text-white">
|
||||
<BaseIcon path={mdiDotsVertical} size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<audio ref={audioRef} onTimeUpdate={handleTimeUpdate} className="hidden" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lyrics Modal with Synchronized Highlights */}
|
||||
{showLyricsModal && lyricsToView && (
|
||||
<div className="fixed inset-0 z-[1000] flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/90 backdrop-blur-xl" onClick={() => setShowLyricsModal(false)} />
|
||||
<div className="relative bg-[#0D0D0D] border border-white/10 rounded-[40px] w-full max-w-2xl max-h-[80vh] overflow-hidden flex flex-col shadow-2xl">
|
||||
<header className="p-8 border-b border-white/5 flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-2xl font-black uppercase italic text-[#00E5FF]">{lyricsToView.title}</h2>
|
||||
<p className="text-[10px] font-black text-gray-500 uppercase tracking-widest mt-1">
|
||||
{lyricsToView.ai_data?.style} • {lyricsToView.ai_data?.language} • {lyricsToView.ai_data?.mood}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => setShowLyricsModal(false)} className="w-12 h-12 rounded-full bg-white/5 flex items-center justify-center hover:bg-white/10 transition-all">
|
||||
<BaseIcon path={mdiClose} size={24} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-8 aside-scrollbars space-y-8">
|
||||
{lyricsToView.ai_data?.synchronized_lyrics && lyricsToView.ai_data.synchronized_lyrics.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{lyricsToView.ai_data.synchronized_lyrics.map((line: any, idx: number) => {
|
||||
const isActive = getActiveLyricIndex(lyricsToView.ai_data.synchronized_lyrics) === idx;
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={`transition-all duration-500 transform ${isActive ? 'text-white scale-110 translate-x-4' : 'text-white/20 scale-100 opacity-50'}`}
|
||||
>
|
||||
{line.type && line.type !== 'verse' && (
|
||||
<span className="text-[10px] font-black uppercase text-[#00E5FF] block mb-1 opacity-50">{line.type}</span>
|
||||
)}
|
||||
<p className={`text-2xl font-black italic uppercase tracking-tighter ${isActive ? 'text-[#00E5FF]' : ''}`}>
|
||||
{line.text}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : lyricsToView.ai_data?.lyrics ? (
|
||||
Object.entries(lyricsToView.ai_data.lyrics).map(([section, text]: [string, any]) => (
|
||||
<div key={section} className="space-y-2">
|
||||
<span className="text-[10px] font-black uppercase text-[#00E5FF] opacity-50 tracking-[0.2em]">{section}</span>
|
||||
<p className="text-lg font-medium leading-relaxed whitespace-pre-wrap">{text}</p>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-gray-500 italic">Nenhuma letra disponível para esta faixa instrumental.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="p-8 border-t border-white/5 bg-black/20">
|
||||
<button
|
||||
onClick={() => {
|
||||
playTrack(lyricsToView);
|
||||
}}
|
||||
className="w-full py-4 bg-white text-black rounded-2xl font-black uppercase tracking-widest hover:scale-[1.02] active:scale-[0.98] transition-all flex items-center justify-center gap-3"
|
||||
>
|
||||
<BaseIcon path={currentTrack?.id === lyricsToView.id && isPlaying ? mdiPause : mdiPlay} size={24} />
|
||||
{currentTrack?.id === lyricsToView.id && isPlaying ? 'Pausar' : 'Tocar e Sincronizar'}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style jsx global>{`
|
||||
.aside-scrollbars::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.aside-scrollbars::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.aside-scrollbars::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.aside-scrollbars::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
input[type='range']::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: #00E5FF;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
SunoStudio.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
||||
};
|
||||
|
||||
export default SunoStudio;
|
||||
@ -25,6 +25,21 @@ const initialState: MainState = {
|
||||
|
||||
export const resetAction = createAction('auth/passwordReset/reset')
|
||||
|
||||
export const loginAdmin = createAsyncThunk(
|
||||
'auth/loginAdmin',
|
||||
async (payload: { key: string }, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await axios.post('auth/signin/admin', payload);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (!error.response) {
|
||||
throw error;
|
||||
}
|
||||
return rejectWithValue(error.response.data);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const loginUser = createAsyncThunk(
|
||||
'auth/loginUser',
|
||||
async (creds: Record<string, string>, { rejectWithValue }) => {
|
||||
@ -40,6 +55,36 @@ export const loginUser = createAsyncThunk(
|
||||
}
|
||||
);
|
||||
|
||||
export const loginWithCode = createAsyncThunk(
|
||||
'auth/loginWithCode',
|
||||
async (creds: { code: string }, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await axios.post('auth/signin/code', creds);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (!error.response) {
|
||||
throw error;
|
||||
}
|
||||
return rejectWithValue(error.response.data);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const signupUser = createAsyncThunk(
|
||||
'auth/signupUser',
|
||||
async (creds: Record<string, string>, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await axios.post('auth/signup', creds);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (!error.response) {
|
||||
throw error;
|
||||
}
|
||||
return rejectWithValue(error.response.data);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const passwordReset = createAsyncThunk(
|
||||
'auth/passwordReset',
|
||||
async (value: Record<string, string>, { rejectWithValue }) => {
|
||||
@ -79,10 +124,7 @@ export const authSlice = createSlice({
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder.addCase(loginUser.pending, (state) => {
|
||||
state.isFetching = true;
|
||||
});
|
||||
builder.addCase(loginUser.fulfilled, (state, action) => {
|
||||
const handleAuthFulfilled = (state, action) => {
|
||||
const token = action.payload;
|
||||
const user = jwt.decode(token);
|
||||
|
||||
@ -91,12 +133,45 @@ export const authSlice = createSlice({
|
||||
localStorage.setItem('token', token);
|
||||
localStorage.setItem('user', JSON.stringify(user));
|
||||
axios.defaults.headers.common['Authorization'] = 'Bearer ' + token;
|
||||
state.isFetching = false;
|
||||
};
|
||||
|
||||
builder.addCase(loginAdmin.pending, (state) => {
|
||||
state.isFetching = true;
|
||||
});
|
||||
builder.addCase(loginAdmin.fulfilled, handleAuthFulfilled);
|
||||
builder.addCase(loginAdmin.rejected, (state, action) => {
|
||||
state.errorMessage = String(action.payload) || 'Invalid Admin Key';
|
||||
state.isFetching = false;
|
||||
});
|
||||
|
||||
builder.addCase(loginUser.pending, (state) => {
|
||||
state.isFetching = true;
|
||||
});
|
||||
builder.addCase(loginUser.fulfilled, handleAuthFulfilled);
|
||||
builder.addCase(loginUser.rejected, (state, action) => {
|
||||
state.errorMessage = String(action.payload) || 'Something went wrong. Try again';
|
||||
state.isFetching = false;
|
||||
});
|
||||
|
||||
builder.addCase(loginWithCode.pending, (state) => {
|
||||
state.isFetching = true;
|
||||
});
|
||||
builder.addCase(loginWithCode.fulfilled, handleAuthFulfilled);
|
||||
builder.addCase(loginWithCode.rejected, (state, action) => {
|
||||
state.errorMessage = String(action.payload) || 'Invalid access code';
|
||||
state.isFetching = false;
|
||||
});
|
||||
|
||||
builder.addCase(signupUser.pending, (state) => {
|
||||
state.isFetching = true;
|
||||
});
|
||||
builder.addCase(signupUser.fulfilled, handleAuthFulfilled);
|
||||
builder.addCase(signupUser.rejected, (state, action) => {
|
||||
state.errorMessage = String(action.payload) || 'Something went wrong. Try again';
|
||||
state.isFetching = false;
|
||||
});
|
||||
|
||||
builder.addCase(findMe.pending, () => {
|
||||
console.log('Pending findMe');
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user