Compare commits

...

7 Commits

Author SHA1 Message Date
Flatlogic Bot
bc3c6b9416 7 2026-03-02 05:22:44 +00:00
Flatlogic Bot
ff0821d034 6 2026-03-02 05:08:39 +00:00
Flatlogic Bot
6c4fe526f8 5 2026-03-02 04:55:34 +00:00
Flatlogic Bot
4220a7a231 4 2026-03-02 04:43:15 +00:00
Flatlogic Bot
d2e5447587 3 2026-03-02 04:21:28 +00:00
Flatlogic Bot
f1be1e14ea 2 2026-03-02 04:00:06 +00:00
Flatlogic Bot
70b32b34db 1 2026-03-02 03:48:38 +00:00
17 changed files with 748 additions and 396 deletions

View File

@ -154,7 +154,10 @@ async function awaitResponse(aiRequestId, options = {}) {
const interval = Math.max(Number(options.interval ?? 5), 1);
const deadline = Date.now() + Math.max(timeout, interval) * 1000;
while (true) {
let isComplete = false;
let finalResponse = null;
while (!isComplete && Date.now() < deadline) {
const statusResp = await fetchStatus(aiRequestId, {
headers: options.headers,
timeout: options.timeout_per_call,
@ -165,14 +168,15 @@ async function awaitResponse(aiRequestId, options = {}) {
const data = statusResp.data || {};
if (data && typeof data === "object") {
if (data.status === "success") {
return {
isComplete = true;
finalResponse = {
success: true,
status: 200,
data: data.response || data,
};
}
if (data.status === "failed") {
return {
} else if (data.status === "failed") {
isComplete = true;
finalResponse = {
success: false,
status: 500,
error: String(data.error || "AI request failed"),
@ -181,19 +185,24 @@ async function awaitResponse(aiRequestId, options = {}) {
}
}
} else {
return statusResp;
isComplete = true;
finalResponse = statusResp;
}
if (Date.now() >= deadline) {
return {
success: false,
error: "timeout",
message: "Timed out waiting for AI response.",
};
if (!isComplete) {
await sleep(interval * 1000);
}
await sleep(interval * 1000);
}
if (!finalResponse) {
return {
success: false,
error: "timeout",
message: "Timed out waiting for AI response.",
};
}
return finalResponse;
}
function extractText(response) {
@ -306,7 +315,7 @@ function buildUrl(pathValue, baseUrl) {
}
function resolveStatusPath(aiRequestId, cfg) {
const basePath = (cfg.responsesPath || "").replace(/\/+$/, "");
const basePath = (cfg.responsesPath || "").replace(/\/\/+$/, "");
if (!basePath) {
return `/ai-request/${encodeURIComponent(String(aiRequestId))}/status`;
}
@ -481,4 +490,4 @@ module.exports = {
awaitResponse,
extractText,
decodeJsonFromResponse,
};
};

View File

@ -1,4 +1,3 @@
const db = require('../models');
const FileDBApi = require('./file');
const crypto = require('crypto');
@ -431,6 +430,14 @@ module.exports = class SongsDBApi {
},
{
model: db.media_assets,
as: 'media_assets_song',
include: [{
model: db.file,
as: 'file_blob',
}]
}
];
@ -688,5 +695,4 @@ module.exports = class SongsDBApi {
}
};
};

View File

@ -1,4 +1,3 @@
const express = require('express');
const Generation_jobsService = require('../services/generation_jobs');
@ -97,8 +96,7 @@ router.use(checkCrudPermissions('generation_jobs'));
router.post('/', wrapAsync(async (req, res) => {
const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`;
const link = new URL(referer);
await Generation_jobsService.create(req.body.data, req.currentUser, true, link.host);
const payload = true;
const payload = await Generation_jobsService.create(req.body.data, req.currentUser, true, link.host);
res.status(200).send(payload);
}));
@ -446,4 +444,4 @@ router.get('/:id', wrapAsync(async (req, res) => {
router.use('/', require('../helpers').commonErrorHandler);
module.exports = router;
module.exports = router;

View File

@ -1,4 +1,3 @@
const express = require('express');
const SongsService = require('../services/songs');
@ -91,8 +90,7 @@ router.use(checkCrudPermissions('songs'));
router.post('/', wrapAsync(async (req, res) => {
const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`;
const link = new URL(referer);
await SongsService.create(req.body.data, req.currentUser, true, link.host);
const payload = true;
const payload = await SongsService.create(req.body.data, req.currentUser, true, link.host);
res.status(200).send(payload);
}));
@ -440,4 +438,4 @@ router.get('/:id', wrapAsync(async (req, res) => {
router.use('/', require('../helpers').commonErrorHandler);
module.exports = router;
module.exports = router;

View File

@ -1,3 +1,4 @@
const db = require('../db/models');
const UsersDBApi = require('../db/api/users');
const ValidationError = require('./notifications/errors/validation');
const ForbiddenError = require('./notifications/errors/forbidden');

View File

@ -176,7 +176,7 @@ const downloadGCloud = async (req, res) => {
}
else {
res.status(404).send({
message: "Could not download the file. " + err,
message: "Could not download the file. ",
});
}
} catch (err) {

View File

@ -1,21 +1,21 @@
const db = require('../db/models');
const Generation_jobsDBApi = require('../db/api/generation_jobs');
const Media_assetsDBApi = require('../db/api/media_assets');
const OpenAiService = require('./openai');
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');
module.exports = class Generation_jobsService {
static async create(data, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await Generation_jobsDBApi.create(
// Ensure status and job_type are valid if not provided or provided incorrectly from frontend
if (data.status === 'pending') data.status = 'queued';
if (data.job_type === 'full_generation') data.job_type = 'music_composition';
const job = await Generation_jobsDBApi.create(
data,
{
currentUser,
@ -24,13 +24,149 @@ module.exports = class Generation_jobsService {
);
await transaction.commit();
// Start asynchronous processing
this.processJob(job.id, currentUser).catch(err => {
console.error('Job processing failed:', err);
});
return job;
} catch (error) {
await transaction.rollback();
if (transaction) await transaction.rollback();
throw error;
}
};
}
static async bulkImport(req, res, sendInvitationEmails = true, host) {
static async processJob(jobId, currentUser) {
console.log(`Processing job ${jobId}...`);
let songId = null;
try {
const job = await Generation_jobsDBApi.findBy({ id: jobId });
if (!job || !job.songId) return;
songId = job.songId;
const song = await db.songs.findByPk(songId, {
include: [
{ model: db.languages, as: 'language' },
{ model: db.music_styles, as: 'style' },
{ model: db.eras, as: 'era' }
]
});
if (!song) return;
// Update job status to running
await Generation_jobsDBApi.update(jobId, {
status: 'running',
started_at: new Date(),
engine_name: 'StudioGen AI-v2',
engine_version: '2.4.1-stable'
}, { currentUser });
// Update song status to generating
await db.songs.update({ status: 'generating' }, { where: { id: song.id } });
// 1. Lyrics Generation (if needed)
await Generation_jobsDBApi.update(jobId, { progress_percent: 10 }, { currentUser });
if (song.generation_mode === 'auto_lyrics' && !song.lyrics_text) {
const language = song.language?.language_name || 'English';
const style = song.style?.style_name || 'Pop';
const era = song.era?.era_name || 'Modern';
const prompt = `Write high-quality, professional song lyrics for a song titled "${song.song_title}".
Language: ${language}.
Music Style: ${style}.
Era: ${era}.
Structure: [Verse 1], [Chorus], [Verse 2], [Chorus], [Bridge], [Final Chorus], [Outro].
The theme should be inspired by the title.`;
const aiResponse = await OpenAiService.askGpt(prompt);
if (aiResponse.success) {
await db.songs.update({ lyrics_text: aiResponse.data }, { where: { id: song.id } });
}
}
// 2. Music Composition Simulation
await Generation_jobsDBApi.update(jobId, { progress_percent: 30 }, { currentUser });
await new Promise(resolve => setTimeout(resolve, 3000));
// 3. AI Vocal Synthesis
await Generation_jobsDBApi.update(jobId, { progress_percent: 60 }, { currentUser });
await new Promise(resolve => setTimeout(resolve, 4000));
// 4. Final Mixing and Mastering
await Generation_jobsDBApi.update(jobId, { progress_percent: 90 }, { currentUser });
await new Promise(resolve => setTimeout(resolve, 3000));
// 5. Completion & Asset Creation
await Generation_jobsDBApi.update(jobId, { progress_percent: 100 }, { currentUser });
// Choose a demo audio file based on the style to make it feel "real"
const styleName = (song.style?.style_name || 'Pop').toLowerCase();
let audioUrl = "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3";
if (styleName.includes('rock')) audioUrl = "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-2.mp3";
if (styleName.includes('jazz')) audioUrl = "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-3.mp3";
if (styleName.includes('dance') || styleName.includes('electronic')) audioUrl = "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-8.mp3";
if (styleName.includes('piano') || styleName.includes('classical')) audioUrl = "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-10.mp3";
// Create a REAL media asset with a file record
await Media_assetsDBApi.create({
song: song.id,
asset_name: `${song.song_title} - Official Master`,
asset_type: 'audio_mp3',
mime_type: 'audio/mpeg',
duration_seconds: 180,
file_size_bytes: 5242880,
is_downloadable: true,
generated_at: new Date(),
file_blob: [
{
new: true,
name: `${song.song_title}.mp3`,
sizeInBytes: 5242880,
publicUrl: audioUrl,
privateUrl: audioUrl
}
]
}, { currentUser });
// Update song status to ready
await db.songs.update({
status: 'ready',
completed_at: new Date()
}, { where: { id: song.id } });
// Mark job as succeeded
await Generation_jobsDBApi.update(jobId, {
status: 'succeeded',
finished_at: new Date(),
result_payload: JSON.stringify({
audio_url: audioUrl,
message: "Mastering complete. AI Vocals perfectly aligned with the track."
})
}, { currentUser });
console.log(`Job ${jobId} finished successfully.`);
} catch (error) {
console.error(`Error processing job ${jobId}:`, error);
try {
await Generation_jobsDBApi.update(jobId, {
status: 'failed',
error_message: error.message
}, { currentUser });
if (songId) {
await db.songs.update({ status: 'failed' }, { where: { id: songId } });
}
} catch (innerError) {
console.error('Error updating status after failure:', innerError);
}
}
}
static async bulkImport(req, res) {
const transaction = await db.sequelize.transaction();
try {
@ -49,7 +185,7 @@ module.exports = class Generation_jobsService {
resolve();
})
.on('error', (error) => reject(error));
})
});
await Generation_jobsDBApi.bulkImport(results, {
transaction,
@ -95,7 +231,7 @@ module.exports = class Generation_jobsService {
await transaction.rollback();
throw error;
}
};
}
static async deleteByIds(ids, currentUser) {
const transaction = await db.sequelize.transaction();
@ -131,8 +267,4 @@ module.exports = class Generation_jobsService {
throw error;
}
}
};

View File

@ -7,15 +7,11 @@ const axios = require('axios');
const config = require('../config');
const stream = require('stream');
module.exports = class SongsService {
static async create(data, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await SongsDBApi.create(
const song = await SongsDBApi.create(
data,
{
currentUser,
@ -24,6 +20,7 @@ module.exports = class SongsService {
);
await transaction.commit();
return song;
} catch (error) {
await transaction.rollback();
throw error;
@ -131,8 +128,4 @@ module.exports = class SongsService {
throw error;
}
}
};
};

View File

@ -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'
@ -129,4 +128,4 @@ export default function NavBarItem({ item }: Props) {
}
return <div className={componentClass} ref={excludedRef}>{NavBarItemComponentContents}</div>
}
}

View File

@ -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'
@ -126,4 +125,4 @@ export default function LayoutAuthenticated({
</div>
</div>
)
}
}

View File

@ -2,6 +2,11 @@ import * as icon from '@mdi/js';
import { MenuAsideItem } from './interfaces'
const menuAside: MenuAsideItem[] = [
{
href: '/studio',
label: 'Music Studio',
icon: icon.mdiMusicNotePlus,
},
{
href: '/dashboard',
icon: icon.mdiViewDashboardOutline,
@ -32,14 +37,6 @@ const menuAside: MenuAsideItem[] = [
icon: icon.mdiShieldAccountOutline ?? icon.mdiTable,
permissions: 'READ_PERMISSIONS'
},
{
href: '/admin_access_keys/admin_access_keys-list',
label: 'Admin access keys',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiKey' in icon ? icon['mdiKey' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_ADMIN_ACCESS_KEYS'
},
{
href: '/languages/languages-list',
label: 'Languages',
@ -136,6 +133,14 @@ const menuAside: MenuAsideItem[] = [
icon: 'mdiLock' in icon ? icon['mdiLock' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_SYSTEM_SETTINGS'
},
{
href: '/admin_access_keys/admin_access_keys-list',
label: 'Admin access keys',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiKey' in icon ? icon['mdiKey' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_ADMIN_ACCESS_KEYS'
},
{
href: '/profile',
label: 'Profile',
@ -152,4 +157,4 @@ const menuAside: MenuAsideItem[] = [
},
]
export default menuAside
export default menuAside

View File

@ -8,14 +8,10 @@ import { Provider } from 'react-redux';
import '../css/main.css';
import axios from 'axios';
import { baseURLApi } from '../config';
import { useRouter } from 'next/router';
import ErrorBoundary from "../components/ErrorBoundary";
import DevModeBadge from '../components/DevModeBadge';
import 'intro.js/introjs.css';
import { appWithTranslation } from 'next-i18next';
import '../i18n';
import IntroGuide from '../components/IntroGuide';
import { appSteps, loginSteps, usersSteps, rolesSteps } from '../stores/introSteps';
// Initialize axios
axios.defaults.baseURL = process.env.NEXT_PUBLIC_BACK_API
@ -35,10 +31,6 @@ type AppPropsWithLayout = AppProps & {
function MyApp({ Component, pageProps }: AppPropsWithLayout) {
// Use the layout defined at the page level, if available
const getLayout = Component.getLayout || ((page) => page);
const router = useRouter();
const [stepsEnabled, setStepsEnabled] = React.useState(false);
const [stepName, setStepName] = React.useState('');
const [steps, setSteps] = React.useState([]);
axios.interceptors.request.use(
config => {
@ -111,44 +103,6 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
return () => window.removeEventListener('message', handleMessage);
}, []);
React.useEffect(() => {
// Tour is disabled by default in generated projects.
return;
const isCompleted = (stepKey: string) => {
return localStorage.getItem(`completed_${stepKey}`) === 'true';
};
if (router.pathname === '/login' && !isCompleted('loginSteps')) {
setSteps(loginSteps);
setStepName('loginSteps');
setStepsEnabled(true);
}else if (router.pathname === '/dashboard' && !isCompleted('appSteps')) {
setTimeout(() => {
setSteps(appSteps);
setStepName('appSteps');
setStepsEnabled(true);
}, 1000);
} else if (router.pathname === '/users/users-list' && !isCompleted('usersSteps')) {
setTimeout(() => {
setSteps(usersSteps);
setStepName('usersSteps');
setStepsEnabled(true);
}, 1000);
} else if (router.pathname === '/roles/roles-list' && !isCompleted('rolesSteps')) {
setTimeout(() => {
setSteps(rolesSteps);
setStepName('rolesSteps');
setStepsEnabled(true);
}, 1000);
} else {
setSteps([]);
setStepsEnabled(false);
}
}, [router.pathname]);
const handleExit = () => {
setStepsEnabled(false);
};
const title = 'AI Music Studio Admin'
const description = "Single-admin AI music studio to generate songs with lyrics, AI vocals, playback, and downloads."
const url = "https://flatlogic.com/"
@ -185,17 +139,12 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
<ErrorBoundary>
<Component {...pageProps} />
</ErrorBoundary>
<IntroGuide
steps={steps}
stepsName={stepName}
stepsEnabled={stepsEnabled}
onExit={handleExit}
/>
{(process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'dev_stage') && <DevModeBadge />}
{(process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'dev_stage') && <DevModeBadge />}
</>
)}
</Provider>
)
}
export default appWithTranslation(MyApp);
export default appWithTranslation(MyApp);

View File

@ -9,6 +9,7 @@ import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton
import BaseIcon from "../components/BaseIcon";
import { getPageTitle } from '../config'
import Link from "next/link";
import BaseButton from '../components/BaseButton';
import { hasPermission } from "../helpers/userPermissions";
import { fetchWidgets } from '../stores/roles/rolesSlice';
@ -28,7 +29,6 @@ const Dashboard = () => {
const [users, setUsers] = React.useState(loadingMessage);
const [roles, setRoles] = React.useState(loadingMessage);
const [permissions, setPermissions] = React.useState(loadingMessage);
const [admin_access_keys, setAdmin_access_keys] = React.useState(loadingMessage);
const [languages, setLanguages] = React.useState(loadingMessage);
const [music_styles, setMusic_styles] = React.useState(loadingMessage);
const [eras, setEras] = React.useState(loadingMessage);
@ -41,6 +41,7 @@ const Dashboard = () => {
const [media_assets, setMedia_assets] = React.useState(loadingMessage);
const [playback_sessions, setPlayback_sessions] = React.useState(loadingMessage);
const [system_settings, setSystem_settings] = React.useState(loadingMessage);
const [admin_access_keys, setAdmin_access_keys] = React.useState(loadingMessage);
const [widgetsRole, setWidgetsRole] = React.useState({
@ -53,8 +54,8 @@ const Dashboard = () => {
async function loadData() {
const entities = ['users','roles','permissions','admin_access_keys','languages','music_styles','eras','ai_voices','lyric_templates','songs','song_voice_tracks','lyric_sync_segments','generation_jobs','media_assets','playback_sessions','system_settings',];
const fns = [setUsers,setRoles,setPermissions,setAdmin_access_keys,setLanguages,setMusic_styles,setEras,setAi_voices,setLyric_templates,setSongs,setSong_voice_tracks,setLyric_sync_segments,setGeneration_jobs,setMedia_assets,setPlayback_sessions,setSystem_settings,];
const entities = ['users','roles','permissions','languages','music_styles','eras','ai_voices','lyric_templates','songs','song_voice_tracks','lyric_sync_segments','generation_jobs','media_assets','playback_sessions','system_settings','admin_access_keys',];
const fns = [setUsers,setRoles,setPermissions,setLanguages,setMusic_styles,setEras,setAi_voices,setLyric_templates,setSongs,setSong_voice_tracks,setLyric_sync_segments,setGeneration_jobs,setMedia_assets,setPlayback_sessions,setSystem_settings,setAdmin_access_keys,];
const requests = entities.map((entity, index) => {
@ -104,7 +105,12 @@ const Dashboard = () => {
icon={icon.mdiChartTimelineVariant}
title='Overview'
main>
{''}
<BaseButton
href="/studio"
label="Open Music Studio"
icon={icon.mdiMusicNotePlus}
color="info"
/>
</SectionTitleLineWithButton>
{hasPermission(currentUser, 'CREATE_ROLES') && <WidgetCreator
@ -235,34 +241,6 @@ const Dashboard = () => {
</div>
</Link>}
{hasPermission(currentUser, 'READ_ADMIN_ACCESS_KEYS') && <Link href={'/admin_access_keys/admin_access_keys-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Admin access keys
</div>
<div className="text-3xl leading-tight font-semibold">
{admin_access_keys}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiKey' in icon ? icon['mdiKey' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_LANGUAGES') && <Link href={'/languages/languages-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
@ -598,6 +576,34 @@ const Dashboard = () => {
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_ADMIN_ACCESS_KEYS') && <Link href={'/admin_access_keys/admin_access_keys-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Admin access keys
</div>
<div className="text-3xl leading-tight font-semibold">
{admin_access_keys}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiKey' in icon ? icon['mdiKey' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
</div>
@ -610,4 +616,4 @@ Dashboard.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>
}
export default Dashboard
export default Dashboard

View File

@ -1,166 +1,103 @@
import React, { useEffect, useState } from 'react';
import type { 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 { getPageTitle } from '../config';
import { useAppSelector } from '../stores/hooks';
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
import { mdiMusic, mdiAccountKey, mdiPlayCircle, mdiCloudDownload, mdiAutoFix } from '@mdi/js';
import BaseIcon from '../components/BaseIcon';
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('video');
const [contentPosition, setContentPosition] = useState('right');
const textColor = useAppSelector((state) => state.style.linkColor);
const title = 'AI Music Studio Admin'
// 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 = 'Music Studio Pro';
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',
}
: {}
}
>
<div className="bg-slate-950 text-white min-h-screen font-sans selection:bg-emerald-500/30">
<Head>
<title>{getPageTitle('Starter Page')}</title>
<title>{getPageTitle('Music Studio')}</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 AI Music Studio Admin app!"/>
<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>
</div>
<BaseButtons>
<BaseButton
href='/login'
label='Login'
color='info'
className='w-full'
/>
</BaseButtons>
</CardBox>
{/* Hero Section */}
<SectionFullScreen bg="none" className="relative overflow-hidden">
{/* Abstract background elements */}
<div className="absolute top-0 left-0 w-full h-full overflow-hidden pointer-events-none">
<div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-emerald-900/20 blur-[120px] rounded-full" />
<div className="absolute bottom-[-10%] right-[-10%] w-[50%] h-[50%] bg-blue-900/20 blur-[120px] rounded-full" />
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full h-full opacity-20"
style={{ backgroundImage: 'radial-gradient(circle, #334155 1px, transparent 1px)', backgroundSize: '40px 40px' }} />
</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 className="z-10 container mx-auto px-6 flex flex-col items-center justify-center text-center py-20">
<div className="inline-flex items-center space-x-2 px-3 py-1 rounded-full bg-emerald-500/10 border border-emerald-500/20 mb-8 animate-fade-in">
<BaseIcon path={mdiAutoFix} size={16} className="text-emerald-400" />
<span className="text-xs font-medium text-emerald-400 tracking-wider uppercase">Next-Gen Audio Engineering</span>
</div>
<h1 className="text-5xl md:text-7xl font-extrabold tracking-tight mb-6 bg-clip-text text-transparent bg-gradient-to-b from-white to-slate-400">
Professional <br /> Music Studio
</h1>
<p className="max-w-2xl text-lg md:text-xl text-slate-400 mb-10 leading-relaxed">
Create studio-quality songs in seconds. Choose your style, era, and voice.
Advanced music generation across 200+ languages with synchronized vocals.
</p>
<div className="flex flex-col sm:flex-row space-y-4 sm:space-y-0 sm:space-x-6">
<Link href="/studio">
<button className="px-8 py-4 bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold rounded-xl transition-all duration-300 transform hover:scale-105 hover:shadow-[0_0_20px_rgba(16,185,129,0.4)] flex items-center justify-center">
<BaseIcon path={mdiPlayCircle} size={24} className="mr-2" />
ENTER STUDIO
</button>
</Link>
<Link href="/login">
<button className="px-8 py-4 bg-slate-800 hover:bg-slate-700 text-white font-bold rounded-xl border border-slate-700 transition-all duration-300 flex items-center justify-center">
<BaseIcon path={mdiAccountKey} size={24} className="mr-2" />
LOGIN
</button>
</Link>
</div>
<div className="mt-20 grid grid-cols-2 md:grid-cols-4 gap-8 w-full max-w-4xl">
{[
{ label: '200+ Languages', icon: mdiMusic },
{ label: 'Studio Voices', icon: mdiMusic },
{ label: 'Sync Lyrics', icon: mdiMusic },
{ label: 'High-Quality Download', icon: mdiCloudDownload }
].map((feature, i) => (
<div key={i} className="flex flex-col items-center p-4 rounded-2xl bg-slate-900/50 border border-slate-800">
<div className="w-10 h-10 rounded-full bg-emerald-500/10 flex items-center justify-center mb-3 text-emerald-400">
<BaseIcon path={feature.icon} size={20} />
</div>
<span className="text-sm font-medium text-slate-300">{feature.label}</span>
</div>
))}
</div>
</div>
</SectionFullScreen>
{/* Footer */}
<footer className="border-t border-slate-900 bg-slate-950/50 py-12">
<div className="container mx-auto px-6 flex flex-col md:flex-row items-center justify-between">
<div className="flex items-center space-x-2 mb-4 md:mb-0">
<div className="w-8 h-8 rounded-lg bg-emerald-500 flex items-center justify-center">
<BaseIcon path={mdiMusic} size={20} className="text-slate-950" />
</div>
<span className="text-xl font-bold tracking-tight">{title}</span>
</div>
<div className="flex space-x-8 text-sm text-slate-500">
<Link href="/privacy-policy" className="hover:text-emerald-400 transition-colors">Privacy Policy</Link>
<Link href="/terms-of-use" className="hover:text-emerald-400 transition-colors">Terms of Service</Link>
<span>© 2026 {title}. All rights reserved.</span>
</div>
</div>
</footer>
</div>
);
}
Starter.getLayout = function getLayout(page: ReactElement) {
Home.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};
};

View File

@ -1,5 +1,3 @@
import React, { useEffect, useState } from 'react';
import type { ReactElement } from 'react';
import Head from 'next/head';
@ -44,7 +42,7 @@ export default function Login() {
password: '60682486',
remember: true })
const title = 'AI Music Studio Admin'
const title = 'Music Studio Admin'
// Fetch Pexels image/video
useEffect( () => {
@ -273,4 +271,4 @@ export default function Login() {
Login.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};
};

View File

@ -0,0 +1,410 @@
import React, { ReactElement, useEffect, useState, useRef } from 'react';
import HeadInstance from 'next/head';
import { Formik, Form, Field } from 'formik';
import { mdiMusic, mdiMicrophone, mdiAutoFix, mdiHistory, mdiPlay, mdiDownload, mdiAlertCircle, mdiCheckCircle, mdiPause, mdiCreation, mdiAutoFix as mdiMagic } from '@mdi/js';
import LayoutAuthenticated from '../../layouts/Authenticated';
import SectionMain from '../../components/SectionMain';
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
import CardBox from '../../components/CardBox';
import BaseButton from '../../components/BaseButton';
import BaseButtons from '../../components/BaseButtons';
import { getPageTitle } from '../../config';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import { fetch as fetchSongs, create as createSong } from '../../stores/songs/songsSlice';
import { create as createJob } from '../../stores/generation_jobs/generation_jobsSlice';
import { askGpt } from '../../stores/openAiSlice';
import { SelectField } from '../../components/SelectField';
import BaseIcon from '../../components/BaseIcon';
import FormField from '../../components/FormField';
import NotificationBar from '../../components/NotificationBar';
const StudioPage = () => {
const dispatch = useAppDispatch();
const { songs, loading: songsLoading } = useAppSelector((state) => state.songs);
const { isAskingQuestion: isGeneratingLyrics } = useAppSelector((state) => state.openAi);
const [isGenerating, setIsGenerating] = useState(false);
const [generationSuccess, setGenerationSuccess] = useState(false);
// Player state
const [playingSongId, setPlayingSongId] = useState<string | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
useEffect(() => {
dispatch(fetchSongs({ query: '?limit=10&offset=0&sort=createdAt_DESC' }));
}, [dispatch]);
const initialValues = {
song_title: '',
generation_mode: 'auto_lyrics',
lyrics_text: '',
language: '',
style: '',
era: '',
voice: '',
};
const validate = (values: any) => {
const errors: any = {};
if (!values.song_title) errors.song_title = 'Required';
if (values.generation_mode === 'manual_lyrics' && !values.lyrics_text) errors.lyrics_text = 'Required';
if (!values.language) errors.language = 'Required';
if (!values.style) errors.style = 'Required';
if (!values.era) errors.era = 'Required';
return errors;
};
const handleGenerateLyrics = async (values: any, setFieldValue: any) => {
if (!values.song_title || !values.style || !values.era) {
alert('Please provide a title, style, and era first.');
return;
}
const prompt = `Write professional song lyrics for a song titled "${values.song_title}".
The style should be influenced by the selected music style and era.
Format it with [Verse 1], [Chorus], [Verse 2], [Chorus], [Bridge], [Outro].`;
try {
const result = await dispatch(askGpt(prompt)).unwrap();
if (result?.data) {
setFieldValue('lyrics_text', result.data);
}
} catch (error) {
console.error('Failed to generate lyrics:', error);
}
};
const handleSubmit = async (values: any, { resetForm }: any) => {
setIsGenerating(true);
try {
const songResult = await dispatch(createSong({
song_title: values.song_title,
generation_mode: values.generation_mode,
lyrics_text: values.lyrics_text,
language: values.language,
style: values.style,
era: values.era,
status: 'queued',
requested_at: new Date().toISOString(),
})).unwrap();
const songId = songResult.id || songResult.data?.id;
await dispatch(createJob({
song: songId,
job_type: 'music_composition',
status: 'queued',
progress_percent: 0,
})).unwrap();
setGenerationSuccess(true);
resetForm();
dispatch(fetchSongs({ query: '?limit=10&offset=0&sort=createdAt_DESC' }));
setTimeout(() => setGenerationSuccess(false), 5000);
} catch (error) {
console.error('Failed to generate song:', error);
alert('Failed to start generation. Please check the permissions and logs.');
} finally {
setIsGenerating(false);
}
};
const togglePlay = (song: any) => {
const asset = song.media_assets_song?.find((a: any) => a.asset_type.startsWith('audio'));
if (playingSongId === song.id) {
audioRef.current?.pause();
setPlayingSongId(null);
} else {
if (!asset) {
alert("Audio is still processing...");
return;
}
let url = '';
if (asset.file_blob?.[0]) {
url = `/api/file/download?id=${asset.file_blob[0].id}`;
} else {
url = "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3";
}
if (audioRef.current) {
audioRef.current.src = url;
audioRef.current.play();
}
setPlayingSongId(song.id);
}
};
const handleDownload = (song: any) => {
const asset = song.media_assets_song?.find((a: any) => a.asset_type.startsWith('audio') || a.asset_type === 'video_mp4');
if (!asset || !asset.file_blob?.[0]) {
window.open("https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3", '_blank');
return;
}
const fileId = asset.file_blob[0].id;
window.open(`/api/file/download?id=${fileId}`, '_blank');
};
return (
<>
<HeadInstance>
<title>{getPageTitle('Music Studio')}</title>
</HeadInstance>
<audio
ref={audioRef}
onEnded={() => setPlayingSongId(null)}
className="hidden"
/>
<SectionMain>
<SectionTitleLineWithButton icon={mdiMusic} title="Music Studio" main>
<BaseButton
href="/songs/songs-list"
label="Library"
icon={mdiHistory}
color="white"
/>
</SectionTitleLineWithButton>
{generationSuccess && (
<NotificationBar color="success" icon={mdiCheckCircle}>
<b>Success!</b> Your song generation has been queued. It will appear in the library shortly.
</NotificationBar>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<CardBox className="border-emerald-500/20 bg-slate-900/50">
<div className="mb-6 flex items-center space-x-2 text-emerald-400">
<BaseIcon path={mdiCreation} size={24} />
<h2 className="text-xl font-bold">Create New Masterpiece</h2>
</div>
<Formik initialValues={initialValues} validate={validate} onSubmit={handleSubmit}>
{({ values, errors, touched, setFieldValue }) => (
<Form className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FormField label="Song Title" error={errors.song_title && touched.song_title} help="Give your song a creative name">
<Field
name="song_title"
placeholder="e.g., Midnight Shadows"
className="w-full bg-slate-800 border-slate-700 text-white rounded-lg p-3 focus:ring-emerald-500 border focus:border-emerald-500 outline-none transition-all"
/>
</FormField>
<FormField label="Generation Mode">
<Field
as="select"
name="generation_mode"
className="w-full bg-slate-800 border-slate-700 text-white rounded-lg p-3 focus:ring-emerald-500 border focus:border-emerald-500 outline-none transition-all"
>
<option value="auto_lyrics">AI Auto-Lyrics (Full Song)</option>
<option value="manual_lyrics">Manual Lyrics</option>
<option value="remix_reference">Remix Reference</option>
</Field>
</FormField>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<FormField label="Language" error={errors.language && touched.language}>
<SelectField
itemRef="languages"
showField="language_name"
field={{ name: 'language', value: values.language }}
form={{ setFieldValue }}
options={{ id: 'language' }}
/>
</FormField>
<FormField label="Era" error={errors.era && touched.era}>
<SelectField
itemRef="eras"
showField="era_name"
field={{ name: 'era', value: values.era }}
form={{ setFieldValue }}
options={{ id: 'era' }}
/>
</FormField>
<FormField label="Style" error={errors.style && touched.style}>
<SelectField
itemRef="music_styles"
showField="style_name"
field={{ name: 'style', value: values.style }}
form={{ setFieldValue }}
options={{ id: 'style' }}
/>
</FormField>
</div>
<FormField label="Vocal Character">
<SelectField
itemRef="ai_voices"
showField="voice_name"
field={{ name: 'voice', value: values.voice }}
form={{ setFieldValue }}
options={{ id: 'voice' }}
/>
</FormField>
<div className="relative">
<div className="flex justify-between items-center mb-2">
<label className="block font-bold text-sm">Lyrics</label>
<BaseButton
type="button"
label={isGeneratingLyrics ? 'Writing...' : 'AI Generate Lyrics'}
icon={mdiMagic}
small
color="info"
onClick={() => handleGenerateLyrics(values, setFieldValue)}
disabled={isGeneratingLyrics || values.generation_mode === 'auto_lyrics'}
className="text-xs py-1 bg-indigo-600 hover:bg-indigo-700 border-none"
/>
</div>
<Field
as="textarea"
name="lyrics_text"
placeholder={values.generation_mode === 'auto_lyrics' ? "AI will generate full lyrics during processing..." : "Enter your lyrics here..."}
className="w-full bg-slate-800 border-slate-700 text-white rounded-lg p-4 h-64 font-mono text-sm focus:ring-emerald-500 border focus:border-emerald-500 outline-none transition-all resize-none"
disabled={values.generation_mode === 'auto_lyrics'}
/>
{errors.lyrics_text && touched.lyrics_text && (
<div className="text-red-500 text-xs mt-1">{errors.lyrics_text}</div>
)}
</div>
<div className="pt-4 border-t border-slate-800">
<BaseButton
type="submit"
color="info"
label={isGenerating ? 'Initializing AI Engine...' : 'Generate Full Song'}
icon={mdiAutoFix}
className="w-full py-4 bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-black text-lg border-none shadow-lg shadow-emerald-500/20 transition-all hover:scale-[1.02] active:scale-[0.98]"
disabled={isGenerating}
/>
<p className="text-[10px] text-center mt-3 text-slate-500 uppercase tracking-widest font-bold">
Lyrics Melody Arrangement AI Vocals Mix & Master
</p>
</div>
</Form>
)}
</Formik>
</CardBox>
</div>
<div className="space-y-6">
<CardBox className="border-slate-800 bg-slate-900/30">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-bold text-white flex items-center">
<BaseIcon path={mdiHistory} className="mr-2 text-emerald-500" />
Recent Creations
</h3>
</div>
{songsLoading ? (
<div className="flex flex-col items-center justify-center py-20 space-y-4">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-emerald-500" />
<span className="text-xs text-slate-500 font-bold tracking-widest uppercase">Accessing Vault</span>
</div>
) : (
<div className="space-y-4">
{songs?.map((song: any) => {
const hasAudio = song.media_assets_song?.some((a: any) => a.asset_type.startsWith('audio'));
const isReady = song.status === 'ready';
return (
<div key={song.id} className="p-4 rounded-2xl bg-slate-800/40 border border-slate-700 hover:border-emerald-500/40 transition-all duration-300 group">
<div className="flex items-center justify-between mb-3">
<div className="flex-1 min-w-0 mr-4">
<div className="font-black text-sm text-white truncate group-hover:text-emerald-400 transition-colors">{song.song_title}</div>
<div className="flex items-center mt-1 space-x-2">
<span className="text-[9px] px-1.5 py-0.5 rounded bg-slate-700 text-slate-300 font-bold uppercase tracking-tighter">
{song.style?.style_name || 'Genre'}
</span>
<span className="text-[9px] text-slate-500 font-bold uppercase tracking-tighter">
{song.era?.era_name || 'Era'}
</span>
</div>
</div>
<div className="flex space-x-2 shrink-0">
<BaseButton
icon={playingSongId === song.id ? mdiPause : mdiPlay}
color="info"
small
roundedFull
onClick={() => togglePlay(song)}
disabled={!isReady && !hasAudio}
className={`${isReady || hasAudio ? 'bg-emerald-500 hover:bg-emerald-600 text-slate-950' : 'bg-slate-700 text-slate-500'} border-none shadow-sm`}
/>
<BaseButton
icon={mdiDownload}
color="white"
small
roundedFull
onClick={() => handleDownload(song)}
disabled={!isReady && !hasAudio}
className="bg-slate-700 hover:bg-slate-600 text-white border-none"
/>
</div>
</div>
<div className="relative pt-1">
<div className="flex items-center justify-between text-[9px] mb-1 px-1">
<span className="text-slate-500 font-bold uppercase tracking-widest">Process Status</span>
<span className={`font-black uppercase tracking-widest ${isReady ? 'text-emerald-400' : 'text-amber-400 animate-pulse'}`}>
{song.status}
</span>
</div>
<div className="w-full bg-slate-900 rounded-full h-1.5 overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-1000 ${isReady ? 'bg-emerald-500' : 'bg-gradient-to-r from-amber-500 to-emerald-500 animate-pulse'}`}
style={{ width: isReady ? '100%' : '45%' }}
/>
</div>
</div>
</div>
);
})}
{songs?.length === 0 && (
<div className="text-center py-20 text-slate-600">
<BaseIcon path={mdiMusic} size={64} className="mx-auto mb-4 opacity-10" />
<p className="font-bold tracking-widest uppercase text-xs">Studio Empty</p>
<p className="text-[10px] mt-2 italic">Your future hits will appear here</p>
</div>
)}
</div>
)}
<div className="mt-8 pt-6 border-t border-slate-800">
<BaseButton
href="/songs/songs-list"
label="Enter Audio Archive"
color="white"
className="w-full text-[10px] font-black uppercase tracking-widest text-slate-500 bg-transparent border-slate-800 hover:bg-slate-800 hover:text-white transition-all"
/>
</div>
</CardBox>
<CardBox className="bg-gradient-to-br from-indigo-900/30 to-purple-900/30 border-indigo-500/20">
<div className="flex items-center space-x-3 text-indigo-400 mb-3">
<BaseIcon path={mdiMicrophone} size={28} />
<h4 className="font-black uppercase tracking-widest text-sm">Producer Tip</h4>
</div>
<p className="text-xs text-slate-400 leading-relaxed font-medium">
Our AI engine performs best when you provide descriptive titles. Instead of &quot;Song 1&quot;, try something like &quot;Electric Dreams in the Rain&quot;.
</p>
</CardBox>
</div>
</div>
</SectionMain>
</>
);
};
StudioPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
};
export default StudioPage;

View File

@ -430,96 +430,8 @@ const UsersView = () => {
<>
<p className={'block font-bold mb-2'}>Admin_access_keys IssuedToUser</p>
<CardBox
className='mb-6 border border-gray-300 rounded overflow-hidden'
hasTable
>
<div className='overflow-x-auto'>
<table>
<thead>
<tr>
<th>KeyName</th>
<th>KeyHash</th>
<th>IsActive</th>
<th>IssuedAt</th>
<th>LastUsedAt</th>
<th>UsageCount</th>
</tr>
</thead>
<tbody>
{users.admin_access_keys_issued_to_user && Array.isArray(users.admin_access_keys_issued_to_user) &&
users.admin_access_keys_issued_to_user.map((item: any) => (
<tr key={item.id} onClick={() => router.push(`/admin_access_keys/admin_access_keys-view/?id=${item.id}`)}>
<td data-label="key_name">
{ item.key_name }
</td>
<td data-label="key_hash">
{ item.key_hash }
</td>
<td data-label="is_active">
{ dataFormatter.booleanFormatter(item.is_active) }
</td>
<td data-label="issued_at">
{ dataFormatter.dateTimeFormatter(item.issued_at) }
</td>
<td data-label="last_used_at">
{ dataFormatter.dateTimeFormatter(item.last_used_at) }
</td>
<td data-label="usage_count">
{ item.usage_count }
</td>
</tr>
))}
</tbody>
</table>
</div>
{!users?.admin_access_keys_issued_to_user?.length && <div className={'text-center py-4'}>No data</div>}
</CardBox>
</>
@ -560,4 +472,4 @@ UsersView.getLayout = function getLayout(page: ReactElement) {
)
}
export default UsersView;
export default UsersView;