Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cad5fba7a6 |
@ -50,6 +50,7 @@ const upload_jobsRoutes = require('./routes/upload_jobs');
|
|||||||
const upload_job_logsRoutes = require('./routes/upload_job_logs');
|
const upload_job_logsRoutes = require('./routes/upload_job_logs');
|
||||||
|
|
||||||
const release_schedulesRoutes = require('./routes/release_schedules');
|
const release_schedulesRoutes = require('./routes/release_schedules');
|
||||||
|
const youtubeReleaseConsoleRoutes = require('./routes/youtube_release_console');
|
||||||
|
|
||||||
|
|
||||||
const getBaseUrl = (url) => {
|
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/release_schedules', passport.authenticate('jwt', {session: false}), release_schedulesRoutes);
|
||||||
|
|
||||||
|
app.use('/api/youtube-release-console', passport.authenticate('jwt', {session: false}), youtubeReleaseConsoleRoutes);
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
'/api/openai',
|
'/api/openai',
|
||||||
passport.authenticate('jwt', { session: false }),
|
passport.authenticate('jwt', { session: false }),
|
||||||
|
|||||||
142
backend/src/routes/youtube_release_console.js
Normal file
142
backend/src/routes/youtube_release_console.js
Normal 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;
|
||||||
@ -3,10 +3,9 @@ import { mdiLogout, mdiClose } from '@mdi/js'
|
|||||||
import BaseIcon from './BaseIcon'
|
import BaseIcon from './BaseIcon'
|
||||||
import AsideMenuList from './AsideMenuList'
|
import AsideMenuList from './AsideMenuList'
|
||||||
import { MenuAsideItem } from '../interfaces'
|
import { MenuAsideItem } from '../interfaces'
|
||||||
import { useAppSelector } from '../stores/hooks'
|
import { useAppDispatch, useAppSelector } from '../stores/hooks'
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { useAppDispatch } from '../stores/hooks';
|
|
||||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import React, {useEffect, useRef} from 'react'
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useState } from 'react'
|
|
||||||
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
||||||
import BaseDivider from './BaseDivider'
|
import BaseDivider from './BaseDivider'
|
||||||
import BaseIcon from './BaseIcon'
|
import BaseIcon from './BaseIcon'
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import React, { ReactNode, useEffect } from 'react'
|
import React, { ReactNode, useEffect, useState } from 'react'
|
||||||
import { useState } from 'react'
|
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||||
import menuAside from '../menuAside'
|
import menuAside from '../menuAside'
|
||||||
|
|||||||
@ -56,6 +56,14 @@ const menuAside: MenuAsideItem[] = [
|
|||||||
icon: 'mdiMicrophoneVariant' in icon ? icon['mdiMicrophoneVariant' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
icon: 'mdiMicrophoneVariant' in icon ? icon['mdiMicrophoneVariant' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||||
permissions: 'READ_ARTISTS'
|
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',
|
href: '/youtube_accounts/youtube_accounts-list',
|
||||||
label: 'Youtube accounts',
|
label: 'Youtube accounts',
|
||||||
|
|||||||
@ -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';
|
const features = [
|
||||||
import type { ReactElement } from 'react';
|
{
|
||||||
import Head from 'next/head';
|
title: 'Channel-ready upload queue',
|
||||||
import Link from 'next/link';
|
description: 'Stage YouTube titles, descriptions, tags, privacy, schedule, and publishing status before an API worker sends the release.',
|
||||||
import BaseButton from '../components/BaseButton';
|
icon: mdiCloudUploadOutline,
|
||||||
import CardBox from '../components/CardBox';
|
},
|
||||||
import SectionFullScreen from '../components/SectionFullScreen';
|
{
|
||||||
import LayoutGuest from '../layouts/Guest';
|
title: 'Artist → Album → Track catalog',
|
||||||
import BaseDivider from '../components/BaseDivider';
|
description: 'Keep release metadata organized around artists, albums, tracks, video assets, and operational upload logs.',
|
||||||
import BaseButtons from '../components/BaseButtons';
|
icon: mdiAlbum,
|
||||||
import { getPageTitle } from '../config';
|
},
|
||||||
import { useAppSelector } from '../stores/hooks';
|
{
|
||||||
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
|
title: 'Admin controls by default',
|
||||||
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
|
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() {
|
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'
|
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 (
|
return (
|
||||||
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
|
<div className='min-h-screen bg-[#07070A] text-white'>
|
||||||
<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>)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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>
|
<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>
|
</Head>
|
||||||
|
|
||||||
<SectionFullScreen bg='violet'>
|
<header className='mx-auto flex max-w-7xl items-center justify-between px-6 py-6'>
|
||||||
<div
|
<Link href='/' className='flex items-center gap-3'>
|
||||||
className={`flex ${
|
<span className='grid h-11 w-11 place-items-center rounded-2xl bg-red-600 shadow-lg shadow-red-900/40'>
|
||||||
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
<BaseIcon path={mdiYoutube} size={24} />
|
||||||
} min-h-screen w-full`}
|
</span>
|
||||||
>
|
<span>
|
||||||
{contentType === 'image' && contentPosition !== 'background'
|
<span className='block text-sm font-black uppercase tracking-[0.28em] text-red-200'>VEVO-style</span>
|
||||||
? imageBlock(illustrationImage)
|
<span className='block text-lg font-black leading-5'>{title}</span>
|
||||||
: null}
|
</span>
|
||||||
{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
|
|
||||||
</Link>
|
</Link>
|
||||||
|
<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 jobs—built 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>
|
||||||
|
|
||||||
|
<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>
|
</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) {
|
Starter.getLayout = function getLayout(page: ReactElement) {
|
||||||
return <LayoutGuest>{page}</LayoutGuest>;
|
return <LayoutGuest>{page}</LayoutGuest>
|
||||||
};
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,7 @@
|
|||||||
import React, { ReactElement, useEffect, useState } from 'react';
|
import React, { ReactElement, useEffect, useState } from 'react';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import 'react-datepicker/dist/react-datepicker.css';
|
import 'react-datepicker/dist/react-datepicker.css';
|
||||||
import { useAppDispatch } from '../stores/hooks';
|
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||||
|
|
||||||
import { useAppSelector } from '../stores/hooks';
|
|
||||||
|
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||||
|
|||||||
415
frontend/src/pages/youtube_uploader/release-console.tsx
Normal file
415
frontend/src/pages/youtube_uploader/release-console.tsx
Normal 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
|
||||||
1
frontend/tsconfig.tsbuildinfo
Normal file
1
frontend/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user