Compare commits

...

1 Commits

Author SHA1 Message Date
Flatlogic Bot
cad5fba7a6 Music Distribution v1 2026-05-29 09:21:33 +00:00
10 changed files with 687 additions and 160 deletions

View File

@ -50,6 +50,7 @@ const upload_jobsRoutes = require('./routes/upload_jobs');
const upload_job_logsRoutes = require('./routes/upload_job_logs');
const release_schedulesRoutes = require('./routes/release_schedules');
const youtubeReleaseConsoleRoutes = require('./routes/youtube_release_console');
const getBaseUrl = (url) => {
@ -137,6 +138,8 @@ app.use('/api/upload_job_logs', passport.authenticate('jwt', {session: false}),
app.use('/api/release_schedules', passport.authenticate('jwt', {session: false}), release_schedulesRoutes);
app.use('/api/youtube-release-console', passport.authenticate('jwt', {session: false}), youtubeReleaseConsoleRoutes);
app.use(
'/api/openai',
passport.authenticate('jwt', { session: false }),

View File

@ -0,0 +1,142 @@
const express = require('express');
const db = require('../db/models');
const wrapAsync = require('../helpers').wrapAsync;
const { checkPermissions } = require('../middlewares/check-permissions');
const router = express.Router();
const VISIBILITY_VALUES = ['private', 'unlisted', 'public'];
const CONTENT_TYPE_VALUES = ['music_video', 'lyric_video', 'visualizer', 'audio'];
const JOB_TYPE_BY_CONTENT_TYPE = {
music_video: 'upload_video',
lyric_video: 'upload_video',
visualizer: 'upload_video',
audio: 'upload_audio_with_static_image',
};
const cleanText = (value) => (typeof value === 'string' ? value.trim() : '');
const ensureEnum = (value, allowed, fallback) => (allowed.includes(value) ? value : fallback);
router.use(checkPermissions('CREATE_UPLOAD_JOBS'));
router.post('/draft-upload', wrapAsync(async (req, res) => {
const data = req.body?.data || {};
const currentUser = req.currentUser;
const trackTitle = cleanText(data.track_title);
const youtubeTitle = cleanText(data.youtube_title) || trackTitle;
const youtubeAccountId = cleanText(data.youtube_account_id);
const contentType = ensureEnum(data.content_type, CONTENT_TYPE_VALUES, 'music_video');
const privacyStatus = ensureEnum(data.privacy_status, VISIBILITY_VALUES, 'private');
const validationErrors = [];
if (!trackTitle) validationErrors.push('Track title is required.');
if (!youtubeTitle) validationErrors.push('YouTube title is required.');
if (!youtubeAccountId) validationErrors.push('A YouTube account/channel is required.');
const youtubeAccount = youtubeAccountId
? await db.youtube_accounts.findOne({ where: { id: youtubeAccountId } })
: null;
if (youtubeAccountId && !youtubeAccount) {
validationErrors.push('Selected YouTube account was not found.');
}
if (validationErrors.length) {
res.status(400).send({ errors: validationErrors });
return;
}
const organizationId = currentUser?.organizations?.id || currentUser?.organizationsId || null;
const now = new Date();
const result = await db.sequelize.transaction(async (transaction) => {
const track = await db.tracks.create({
title: trackTitle,
track_number: data.track_number ? Number(data.track_number) : null,
isrc: cleanText(data.isrc) || null,
duration_seconds: data.duration_seconds ? Number(data.duration_seconds) : null,
content_type: contentType,
language: cleanText(data.language) || 'en',
explicit: Boolean(data.explicit),
version: cleanText(data.version) || null,
description: cleanText(data.description) || null,
status: 'ready_for_upload',
albumId: cleanText(data.album_id) || null,
artistId: cleanText(data.artist_id) || null,
organizationsId: organizationId,
createdById: currentUser.id,
updatedById: currentUser.id,
}, { transaction });
const asset = await db.youtube_video_assets.create({
title: youtubeTitle,
description: cleanText(data.youtube_description) || cleanText(data.description) || null,
tags_csv: cleanText(data.tags_csv) || null,
category: 'music',
privacy_status: privacyStatus,
made_for_kids: Boolean(data.made_for_kids),
scheduled_publish_at: data.scheduled_publish_at || null,
trackId: track.id,
youtube_accountId: youtubeAccount.id,
organizationsId: organizationId,
createdById: currentUser.id,
updatedById: currentUser.id,
}, { transaction });
const job = await db.upload_jobs.create({
job_type: JOB_TYPE_BY_CONTENT_TYPE[contentType],
status: 'queued',
attempt_count: 0,
max_attempts: 3,
queued_at: now,
progress_percent: 0,
error_code: null,
error_message: null,
provider_request_identifier: `yt-draft-${now.getTime()}`,
youtube_accountId: youtubeAccount.id,
trackId: track.id,
youtube_video_assetId: asset.id,
requested_by_userId: currentUser.id,
organizationsId: organizationId,
createdById: currentUser.id,
updatedById: currentUser.id,
}, { transaction });
await db.upload_job_logs.create({
logged_at: now,
level: 'info',
message: 'Upload package created and queued for YouTube publishing review.',
details_json: JSON.stringify({
youtubeTitle,
privacyStatus,
channel: youtubeAccount.channel_title || youtubeAccount.account_name,
}),
upload_jobId: job.id,
organizationsId: organizationId,
createdById: currentUser.id,
updatedById: currentUser.id,
}, { transaction });
return {
track: { id: track.id, title: track.title },
youtube_video_asset: { id: asset.id, title: asset.title, privacy_status: asset.privacy_status },
upload_job: { id: job.id, status: job.status, job_type: job.job_type, queued_at: job.queued_at },
youtube_account: {
id: youtubeAccount.id,
account_name: youtubeAccount.account_name,
channel_title: youtubeAccount.channel_title,
channel_handle: youtubeAccount.channel_handle,
auth_status: youtubeAccount.auth_status,
},
};
});
res.status(201).send(result);
}));
router.use('/', require('../helpers').commonErrorHandler);
module.exports = router;

View File

@ -3,10 +3,9 @@ import { mdiLogout, mdiClose } from '@mdi/js'
import BaseIcon from './BaseIcon'
import AsideMenuList from './AsideMenuList'
import { MenuAsideItem } from '../interfaces'
import { useAppSelector } from '../stores/hooks'
import { useAppDispatch, useAppSelector } from '../stores/hooks'
import Link from 'next/link';
import { useAppDispatch } from '../stores/hooks';
import { createAsyncThunk } from '@reduxjs/toolkit';
import axios from 'axios';

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'

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'

View File

@ -56,6 +56,14 @@ const menuAside: MenuAsideItem[] = [
icon: 'mdiMicrophoneVariant' in icon ? icon['mdiMicrophoneVariant' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_ARTISTS'
},
{
href: '/youtube_uploader/release-console',
label: 'Release Console',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiYoutubeStudio' in icon ? icon['mdiYoutubeStudio' as keyof typeof icon] : icon.mdiYoutube ?? icon.mdiTable,
permissions: 'CREATE_UPLOAD_JOBS'
},
{
href: '/youtube_accounts/youtube_accounts-list',
label: 'Youtube accounts',

View File

@ -1,166 +1,129 @@
import { mdiAlbum, mdiChartTimelineVariant, mdiCloudUploadOutline, mdiShieldCheckOutline, mdiYoutube } from '@mdi/js'
import Head from 'next/head'
import Link from 'next/link'
import React, { ReactElement } from 'react'
import BaseButton from '../components/BaseButton'
import BaseIcon from '../components/BaseIcon'
import CardBox from '../components/CardBox'
import { getPageTitle } from '../config'
import LayoutGuest from '../layouts/Guest'
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';
const features = [
{
title: 'Channel-ready upload queue',
description: 'Stage YouTube titles, descriptions, tags, privacy, schedule, and publishing status before an API worker sends the release.',
icon: mdiCloudUploadOutline,
},
{
title: 'Artist → Album → Track catalog',
description: 'Keep release metadata organized around artists, albums, tracks, video assets, and operational upload logs.',
icon: mdiAlbum,
},
{
title: 'Admin controls by default',
description: 'Use authenticated team access, roles, permissions, audit history, and retry-friendly job records.',
icon: mdiShieldCheckOutline,
},
]
const workflow = ['Connect channel', 'Prepare metadata', 'Queue upload', 'Review status']
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 = 'Vevo Style Uploader'
// 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>)
}
};
const title = 'Vevo Style Uploader'
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='min-h-screen bg-[#07070A] text-white'>
<Head>
<title>{getPageTitle('Starter Page')}</title>
<title>{getPageTitle('YouTube Music Distribution')}</title>
<meta name='description' content='A Vevo-style music distribution console for staging YouTube music video uploads, albums, tracks, and release jobs.' />
</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 Vevo Style Uploader 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>
</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
<header className='mx-auto flex max-w-7xl items-center justify-between px-6 py-6'>
<Link href='/' className='flex items-center gap-3'>
<span className='grid h-11 w-11 place-items-center rounded-2xl bg-red-600 shadow-lg shadow-red-900/40'>
<BaseIcon path={mdiYoutube} size={24} />
</span>
<span>
<span className='block text-sm font-black uppercase tracking-[0.28em] text-red-200'>VEVO-style</span>
<span className='block text-lg font-black leading-5'>{title}</span>
</span>
</Link>
</div>
<nav className='flex items-center gap-3 text-sm font-semibold'>
<Link href='/privacy-policy' className='hidden text-slate-300 transition hover:text-white sm:inline'>Privacy</Link>
<BaseButton href='/login' color='lightDark' label='Login' />
</nav>
</header>
<main>
<section className='relative overflow-hidden px-6 pb-16 pt-10'>
<div className='absolute inset-0 -z-0 bg-[radial-gradient(circle_at_20%_10%,rgba(239,68,68,0.42),transparent_28%),radial-gradient(circle_at_80%_0%,rgba(168,85,247,0.22),transparent_24%)]' />
<div className='relative z-10 mx-auto grid max-w-7xl items-center gap-10 lg:grid-cols-[1.08fr_0.92fr]'>
<div>
<div className='mb-5 inline-flex items-center rounded-full border border-white/10 bg-white/10 px-4 py-2 text-xs font-bold uppercase tracking-[0.24em] text-red-100 backdrop-blur'>
YouTube music distribution MVP
</div>
<h1 className='max-w-4xl text-5xl font-black tracking-tight md:text-7xl'>Upload music releases like a modern label ops team.</h1>
<p className='mt-6 max-w-2xl text-lg leading-8 text-slate-300'>
A polished admin workflow for preparing artists, albums, tracks, music videos, and YouTube upload jobsbuilt for a Vevo-style channel pipeline.
</p>
<div className='mt-8 flex flex-col gap-3 sm:flex-row'>
<BaseButton href='/youtube_uploader/release-console' color='danger' label='Open admin release console' icon={mdiChartTimelineVariant} />
<BaseButton href='/login' color='lightDark' label='Login to dashboard' />
</div>
<p className='mt-4 text-sm text-slate-400'>Admin access is required for the release console. The login link remains available in the header.</p>
</div>
<div className='rounded-[2rem] border border-white/10 bg-white/10 p-3 shadow-2xl shadow-red-950/30 backdrop-blur'>
<div className='rounded-[1.5rem] bg-[#101014] p-5'>
<div className='mb-5 flex items-center justify-between border-b border-white/10 pb-4'>
<div>
<p className='text-xs uppercase tracking-[0.22em] text-red-300'>Release package</p>
<h2 className='text-2xl font-black'>Official Video Upload</h2>
</div>
<span className='rounded-full bg-amber-400/15 px-3 py-1 text-xs font-bold text-amber-200 ring-1 ring-amber-300/20'>Queued</span>
</div>
<div className='space-y-3'>
{workflow.map((item, index) => (
<div key={item} className='flex items-center gap-4 rounded-2xl bg-white/[0.06] p-4'>
<span className='grid h-9 w-9 place-items-center rounded-full bg-red-600 text-sm font-black'>{index + 1}</span>
<div>
<p className='font-bold'>{item}</p>
<p className='text-sm text-slate-400'>{index === 0 ? '@YourLabelVEVO' : index === 1 ? 'Title, tags, thumbnail, schedule' : index === 2 ? 'Upload job + audit log' : 'Errors, retries, history'}</p>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</section>
<section className='mx-auto grid max-w-7xl gap-5 px-6 pb-16 md:grid-cols-3'>
{features.map((feature) => (
<CardBox key={feature.title} className='border-0 bg-white text-slate-950 shadow-xl shadow-black/20'>
<span className='mb-5 grid h-12 w-12 place-items-center rounded-2xl bg-red-50 text-red-600'>
<BaseIcon path={feature.icon} size={24} />
</span>
<h3 className='text-xl font-black'>{feature.title}</h3>
<p className='mt-3 text-sm leading-6 text-slate-600'>{feature.description}</p>
</CardBox>
))}
</section>
</main>
<footer className='border-t border-white/10 px-6 py-8 text-sm text-slate-400'>
<div className='mx-auto flex max-w-7xl flex-col justify-between gap-4 md:flex-row md:items-center'>
<p>© 2026 {title}. All rights reserved.</p>
<div className='flex gap-4'>
<Link href='/privacy-policy' className='hover:text-white'>Privacy Policy</Link>
<Link href='/login' className='hover:text-white'>Admin Login</Link>
</div>
</div>
</footer>
</div>
);
)
}
Starter.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};
return <LayoutGuest>{page}</LayoutGuest>
}

View File

@ -1,9 +1,7 @@
import React, { ReactElement, useEffect, useState } from 'react';
import Head from 'next/head';
import 'react-datepicker/dist/react-datepicker.css';
import { useAppDispatch } from '../stores/hooks';
import { useAppSelector } from '../stores/hooks';
import { useAppDispatch, useAppSelector } from '../stores/hooks';
import { useRouter } from 'next/router';
import LayoutAuthenticated from '../layouts/Authenticated';

View File

@ -0,0 +1,415 @@
import {
mdiAlbum,
mdiAlertCircleOutline,
mdiCheckCircleOutline,
mdiCloudUploadOutline,
mdiMusicNote,
mdiPlus,
mdiRefresh,
mdiYoutube,
} from '@mdi/js'
import axios from 'axios'
import Head from 'next/head'
import Link from 'next/link'
import React, { ReactElement, useEffect, useMemo, useState } from 'react'
import BaseButton from '../../components/BaseButton'
import BaseButtons from '../../components/BaseButtons'
import BaseIcon from '../../components/BaseIcon'
import CardBox from '../../components/CardBox'
import FormField from '../../components/FormField'
import NotificationBar from '../../components/NotificationBar'
import SectionMain from '../../components/SectionMain'
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
import { getPageTitle } from '../../config'
import LayoutAuthenticated from '../../layouts/Authenticated'
import { useAppSelector } from '../../stores/hooks'
type OptionRecord = {
id: string
title?: string
name?: string
account_name?: string
channel_title?: string
channel_handle?: string
auth_status?: string
}
type UploadJob = {
id: string
status?: string
job_type?: string
queued_at?: string
provider_request_identifier?: string
youtube_account?: OptionRecord
track?: OptionRecord
youtube_video_asset?: OptionRecord
}
type FormState = {
youtube_account_id: string
artist_id: string
album_id: string
track_title: string
youtube_title: string
youtube_description: string
tags_csv: string
content_type: string
privacy_status: string
scheduled_publish_at: string
isrc: string
duration_seconds: string
explicit: boolean
}
const initialForm: FormState = {
youtube_account_id: '',
artist_id: '',
album_id: '',
track_title: '',
youtube_title: '',
youtube_description: '',
tags_csv: '',
content_type: 'music_video',
privacy_status: 'private',
scheduled_publish_at: '',
isrc: '',
duration_seconds: '',
explicit: false,
}
const statusStyles = {
queued: 'bg-amber-100 text-amber-800 ring-amber-200',
running: 'bg-blue-100 text-blue-800 ring-blue-200',
succeeded: 'bg-emerald-100 text-emerald-800 ring-emerald-200',
failed: 'bg-red-100 text-red-800 ring-red-200',
canceled: 'bg-slate-100 text-slate-700 ring-slate-200',
retrying: 'bg-purple-100 text-purple-800 ring-purple-200',
}
const inputClass = 'w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm shadow-sm outline-none transition focus:border-red-400 focus:ring-2 focus:ring-red-100 dark:border-dark-700 dark:bg-dark-900'
const labelFor = (item?: OptionRecord) => item?.title || item?.name || item?.channel_title || item?.account_name || 'Untitled'
const ReleaseConsolePage = () => {
const { currentUser } = useAppSelector((state) => state.auth)
const [form, setForm] = useState<FormState>(initialForm)
const [youtubeAccounts, setYoutubeAccounts] = useState<OptionRecord[]>([])
const [artists, setArtists] = useState<OptionRecord[]>([])
const [albums, setAlbums] = useState<OptionRecord[]>([])
const [jobs, setJobs] = useState<UploadJob[]>([])
const [loadingData, setLoadingData] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState<null | {
track: OptionRecord
youtube_video_asset: OptionRecord
upload_job: UploadJob
youtube_account: OptionRecord
}>(null)
const defaultAccount = useMemo(
() => youtubeAccounts.find((account) => account.auth_status === 'connected') || youtubeAccounts[0],
[youtubeAccounts]
)
const selectedAccount = youtubeAccounts.find((account) => account.id === form.youtube_account_id)
const loadData = async () => {
setLoadingData(true)
setError('')
try {
const [accountsResponse, artistsResponse, albumsResponse, jobsResponse] = await Promise.all([
axios.get('/youtube_accounts?limit=100&page=0'),
axios.get('/artists?limit=100&page=0'),
axios.get('/albums?limit=100&page=0'),
axios.get('/upload_jobs?limit=6&page=0'),
])
const accounts = accountsResponse.data?.rows || []
setYoutubeAccounts(accounts)
setArtists(artistsResponse.data?.rows || [])
setAlbums(albumsResponse.data?.rows || [])
setJobs(jobsResponse.data?.rows || [])
if (!form.youtube_account_id && accounts.length) {
const connected = accounts.find((account) => account.auth_status === 'connected') || accounts[0]
setForm((current) => ({ ...current, youtube_account_id: connected.id }))
}
} catch (loadError) {
console.error('Failed to load YouTube release console data:', loadError)
setError('Could not load catalog data. Please refresh or check your permissions.')
} finally {
setLoadingData(false)
}
}
useEffect(() => {
if (!currentUser) return
loadData().then()
}, [currentUser])
const updateField = (field: keyof FormState, value: string | boolean) => {
setForm((current) => ({ ...current, [field]: value }))
}
const validate = () => {
const messages = []
if (!form.youtube_account_id) messages.push('Choose a YouTube channel.')
if (!form.track_title.trim()) messages.push('Add a track or video title.')
if (!form.youtube_title.trim()) messages.push('Add the public YouTube title.')
if (form.duration_seconds && Number(form.duration_seconds) <= 0) messages.push('Duration must be greater than 0 seconds.')
return messages
}
const submitDraft = async (event: React.FormEvent) => {
event.preventDefault()
setError('')
setSuccess(null)
const validationMessages = validate()
if (validationMessages.length) {
setError(validationMessages.join(' '))
return
}
setSubmitting(true)
try {
const response = await axios.post('/youtube-release-console/draft-upload', { data: form })
setSuccess(response.data)
setForm({ ...initialForm, youtube_account_id: form.youtube_account_id })
await loadData()
} catch (submitError) {
console.error('Failed to create YouTube upload package:', submitError)
const messages = submitError?.response?.data?.errors
setError(Array.isArray(messages) ? messages.join(' ') : 'Upload package could not be queued. Please review the form and try again.')
} finally {
setSubmitting(false)
}
}
const healthItems = [
{ label: 'Channel', value: selectedAccount ? labelFor(selectedAccount) : 'Not selected', icon: mdiYoutube },
{ label: 'Catalog', value: `${artists.length} artists · ${albums.length} albums`, icon: mdiAlbum },
{ label: 'Queue', value: `${jobs.length} recent jobs`, icon: mdiCloudUploadOutline },
]
return (
<>
<Head>
<title>{getPageTitle('YouTube Release Console')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiYoutube} title='YouTube Release Console' main>
<BaseButton href='/upload_jobs/upload_jobs-list' color='info' label='View all jobs' icon={mdiCloudUploadOutline} />
</SectionTitleLineWithButton>
<div className='mb-6 overflow-hidden rounded-3xl bg-slate-950 text-white shadow-2xl shadow-red-950/20'>
<div className='grid gap-6 bg-[radial-gradient(circle_at_top_left,_rgba(239,68,68,0.38),_transparent_30%),linear-gradient(135deg,#111827,#020617_58%,#450a0a)] p-6 lg:grid-cols-[1.4fr_0.8fr] lg:p-8'>
<div>
<div className='mb-4 inline-flex items-center rounded-full border border-white/15 bg-white/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.24em] text-red-100'>
VEVO-style release ops
</div>
<h1 className='max-w-3xl text-3xl font-black tracking-tight md:text-5xl'>Stage music videos, validate metadata, and queue YouTube publishing jobs.</h1>
<p className='mt-4 max-w-2xl text-sm leading-6 text-slate-200 md:text-base'>
This first workflow creates a catalog track, YouTube video asset, upload job, and audit log in one guided pass. The actual YouTube API handoff can attach to this queue next.
</p>
<div className='mt-6 flex flex-wrap gap-3'>
<BaseButton href='/youtube_accounts/youtube_accounts-list' color='danger' label='Manage channels' icon={mdiYoutube} />
<BaseButton href='/albums/albums-list' color='lightDark' label='Album catalog' icon={mdiAlbum} />
</div>
</div>
<div className='grid gap-3'>
{healthItems.map((item) => (
<div key={item.label} className='rounded-2xl border border-white/10 bg-white/10 p-4 backdrop-blur'>
<div className='flex items-center gap-3'>
<span className='rounded-2xl bg-red-500/20 p-3 text-red-100'>
<BaseIcon path={item.icon} size={22} />
</span>
<div>
<p className='text-xs uppercase tracking-[0.18em] text-slate-300'>{item.label}</p>
<p className='font-semibold'>{item.value}</p>
</div>
</div>
</div>
))}
</div>
</div>
</div>
{error && (
<NotificationBar color='danger' icon={mdiAlertCircleOutline}>
{error}
</NotificationBar>
)}
{success && (
<NotificationBar color='success' icon={mdiCheckCircleOutline}>
Upload package queued. Open the{' '}
<Link className='font-semibold underline' href={`/upload_jobs/${success.upload_job.id}`}>
job detail
</Link>{' '}
or review the{' '}
<Link className='font-semibold underline' href={`/youtube_video_assets/${success.youtube_video_asset.id}`}>
YouTube asset
</Link>
.
</NotificationBar>
)}
<div className='grid gap-6 xl:grid-cols-[1.15fr_0.85fr]'>
<CardBox className='overflow-hidden'>
<form onSubmit={submitDraft} className='space-y-6'>
<div className='flex flex-col justify-between gap-3 border-b border-slate-100 pb-5 dark:border-dark-700 md:flex-row md:items-center'>
<div>
<p className='text-xs font-bold uppercase tracking-[0.22em] text-red-500'>Create upload package</p>
<h2 className='text-2xl font-black text-slate-900 dark:text-white'>Release metadata</h2>
</div>
<span className='rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600 dark:bg-dark-800 dark:text-slate-300'>Step 1 of YouTube pipeline</span>
</div>
{loadingData ? (
<div className='rounded-2xl border border-dashed border-slate-200 p-8 text-center text-slate-500'>Loading channels and catalog</div>
) : (
<>
<div className='grid gap-4 md:grid-cols-2'>
<FormField label='YouTube channel' labelFor='youtube_account_id'>
<select id='youtube_account_id' className={inputClass} value={form.youtube_account_id} onChange={(event) => updateField('youtube_account_id', event.target.value)}>
<option value=''>Select channel</option>
{youtubeAccounts.map((account) => (
<option key={account.id} value={account.id}>
{labelFor(account)} {account.channel_handle ? `(${account.channel_handle})` : ''}
</option>
))}
</select>
</FormField>
<FormField label='Content type' labelFor='content_type'>
<select id='content_type' className={inputClass} value={form.content_type} onChange={(event) => updateField('content_type', event.target.value)}>
<option value='music_video'>Music video</option>
<option value='lyric_video'>Lyric video</option>
<option value='visualizer'>Visualizer</option>
<option value='audio'>Audio with static image</option>
</select>
</FormField>
<FormField label='Artist' labelFor='artist_id'>
<select id='artist_id' className={inputClass} value={form.artist_id} onChange={(event) => updateField('artist_id', event.target.value)}>
<option value=''>Optional artist</option>
{artists.map((artist) => (
<option key={artist.id} value={artist.id}>{labelFor(artist)}</option>
))}
</select>
</FormField>
<FormField label='Album' labelFor='album_id'>
<select id='album_id' className={inputClass} value={form.album_id} onChange={(event) => updateField('album_id', event.target.value)}>
<option value=''>Optional album/single</option>
{albums.map((album) => (
<option key={album.id} value={album.id}>{labelFor(album)}</option>
))}
</select>
</FormField>
</div>
{!youtubeAccounts.length && (
<div className='rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900'>
No YouTube accounts exist yet. Create one in <Link className='font-semibold underline' href='/youtube_accounts/youtube_accounts-new'>YouTube accounts</Link> before queueing a package.
</div>
)}
<div className='grid gap-4 md:grid-cols-2'>
<FormField label='Track/video title' labelFor='track_title'>
<input id='track_title' className={inputClass} value={form.track_title} onChange={(event) => updateField('track_title', event.target.value)} placeholder='Example: Midnight Drive' />
</FormField>
<FormField label='Public YouTube title' labelFor='youtube_title'>
<input id='youtube_title' className={inputClass} value={form.youtube_title} onChange={(event) => updateField('youtube_title', event.target.value)} placeholder='Artist - Midnight Drive (Official Video)' />
</FormField>
<FormField label='ISRC' labelFor='isrc'>
<input id='isrc' className={inputClass} value={form.isrc} onChange={(event) => updateField('isrc', event.target.value)} placeholder='US-XXX-26-00001' />
</FormField>
<FormField label='Duration seconds' labelFor='duration_seconds'>
<input id='duration_seconds' type='number' min='1' className={inputClass} value={form.duration_seconds} onChange={(event) => updateField('duration_seconds', event.target.value)} placeholder='214' />
</FormField>
</div>
<FormField label='YouTube description' labelFor='youtube_description'>
<textarea id='youtube_description' className={`${inputClass} min-h-28`} value={form.youtube_description} onChange={(event) => updateField('youtube_description', event.target.value)} placeholder='Credits, label copy, streaming links, copyright notice…' />
</FormField>
<div className='grid gap-4 md:grid-cols-3'>
<FormField label='Tags CSV' labelFor='tags_csv'>
<input id='tags_csv' className={inputClass} value={form.tags_csv} onChange={(event) => updateField('tags_csv', event.target.value)} placeholder='artist, official video, pop' />
</FormField>
<FormField label='Privacy' labelFor='privacy_status'>
<select id='privacy_status' className={inputClass} value={form.privacy_status} onChange={(event) => updateField('privacy_status', event.target.value)}>
<option value='private'>Private</option>
<option value='unlisted'>Unlisted</option>
<option value='public'>Public</option>
</select>
</FormField>
<FormField label='Schedule' labelFor='scheduled_publish_at'>
<input id='scheduled_publish_at' type='datetime-local' className={inputClass} value={form.scheduled_publish_at} onChange={(event) => updateField('scheduled_publish_at', event.target.value)} />
</FormField>
</div>
<label className='flex items-center gap-3 rounded-2xl border border-slate-200 bg-slate-50 p-4 text-sm dark:border-dark-700 dark:bg-dark-900'>
<input type='checkbox' checked={form.explicit} onChange={(event) => updateField('explicit', event.target.checked)} className='h-4 w-4 rounded border-slate-300 text-red-600 focus:ring-red-500' />
Mark this track as explicit in the internal catalog.
</label>
<BaseButtons>
<BaseButton type='submit' color='danger' label={submitting ? 'Queueing…' : 'Queue YouTube upload draft'} icon={submitting ? mdiRefresh : mdiPlus} disabled={submitting || !youtubeAccounts.length} />
<BaseButton color='lightDark' label='Reset' onClick={() => setForm({ ...initialForm, youtube_account_id: defaultAccount?.id || '' })} />
</BaseButtons>
</>
)}
</form>
</CardBox>
<div className='space-y-6'>
<CardBox>
<div className='mb-4 flex items-center justify-between gap-3'>
<div>
<p className='text-xs font-bold uppercase tracking-[0.22em] text-red-500'>Recent queue</p>
<h2 className='text-xl font-black text-slate-900 dark:text-white'>Upload jobs</h2>
</div>
<BaseButton small color='white' icon={mdiRefresh} label='Refresh' onClick={loadData} />
</div>
<div className='space-y-3'>
{jobs.length ? jobs.map((job) => (
<Link key={job.id} href={`/upload_jobs/${job.id}`} className='block rounded-2xl border border-slate-100 p-4 transition hover:-translate-y-0.5 hover:border-red-200 hover:shadow-lg dark:border-dark-700'>
<div className='flex items-start justify-between gap-3'>
<div>
<p className='font-semibold text-slate-900 dark:text-white'>{job.youtube_video_asset?.title || job.track?.title || job.provider_request_identifier || 'YouTube upload job'}</p>
<p className='mt-1 text-xs text-slate-500'>{job.job_type || 'upload'} · {job.youtube_account?.channel_title || job.youtube_account?.account_name || 'channel pending'}</p>
</div>
<span className={`rounded-full px-2.5 py-1 text-xs font-bold ring-1 ${statusStyles[job.status] || statusStyles.queued}`}>{job.status || 'queued'}</span>
</div>
</Link>
)) : (
<div className='rounded-2xl border border-dashed border-slate-200 p-8 text-center dark:border-dark-700'>
<BaseIcon path={mdiMusicNote} size={42} className='mx-auto mb-3 text-slate-300' />
<p className='font-semibold'>No upload jobs yet</p>
<p className='mt-1 text-sm text-slate-500'>Create the first release package to see queue history here.</p>
</div>
)}
</div>
</CardBox>
<CardBox>
<h2 className='text-xl font-black text-slate-900 dark:text-white'>What is wired now</h2>
<ul className='mt-4 space-y-3 text-sm text-slate-600 dark:text-slate-300'>
<li className='flex gap-3'><span className='mt-1 h-2 w-2 rounded-full bg-red-500' />Creates a catalog track/video record.</li>
<li className='flex gap-3'><span className='mt-1 h-2 w-2 rounded-full bg-red-500' />Creates YouTube metadata with title, description, tags, privacy, and schedule.</li>
<li className='flex gap-3'><span className='mt-1 h-2 w-2 rounded-full bg-red-500' />Queues an upload job and writes the first operational log entry.</li>
</ul>
</CardBox>
</div>
</div>
</SectionMain>
</>
)
}
ReleaseConsolePage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated permission='CREATE_UPLOAD_JOBS'>{page}</LayoutAuthenticated>
}
export default ReleaseConsolePage

File diff suppressed because one or more lines are too long