445 lines
19 KiB
TypeScript
445 lines
19 KiB
TypeScript
|
'use client';
|
||
|
|
||
|
|
||
|
import { IVtuber } from "@futureporn/types";
|
||
|
import { useSearchParams } from 'next/navigation';
|
||
|
import React from 'react';
|
||
|
import AwsS3 from '@uppy/aws-s3';
|
||
|
import RemoteSources from '@uppy/remote-sources';
|
||
|
import { LoginButton, useAuth } from '@/app/components/auth';
|
||
|
import { Dashboard } from '@uppy/react';
|
||
|
import styles from '@/assets/styles/fp.module.css'
|
||
|
import { projektMelodyEpoch } from "@/app/lib/constants";
|
||
|
import add from "date-fns/add";
|
||
|
import sub from "date-fns/sub";
|
||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||
|
import { faEraser, faPaperPlane, faSpinner, faX, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||
|
import { useForm, ValidationMode } from 'react-hook-form';
|
||
|
import { yupResolver } from '@hookform/resolvers/yup';
|
||
|
import * as Yup from 'yup';
|
||
|
import { toast } from "react-toastify";
|
||
|
import { ErrorMessage } from "@hookform/error-message"
|
||
|
import Uppy from '@uppy/core';
|
||
|
import { companionUrl } from '@/app/lib/constants';
|
||
|
|
||
|
|
||
|
|
||
|
interface IUploadFormProps {
|
||
|
vtubers: IVtuber[];
|
||
|
}
|
||
|
|
||
|
interface IValidationResults {
|
||
|
valid: boolean;
|
||
|
issues: string[] | null;
|
||
|
}
|
||
|
|
||
|
interface IFormSchema extends Yup.InferType<typeof validationSchema> { };
|
||
|
|
||
|
|
||
|
const validationSchema = Yup.object().shape({
|
||
|
vtuber: Yup.number()
|
||
|
.required('VTuber is required'),
|
||
|
streamCuid: Yup.string().optional(),
|
||
|
date: Yup.date()
|
||
|
.typeError('Invalid date') // https://stackoverflow.com/a/72985532/1004931
|
||
|
.min(sub(projektMelodyEpoch, { days: 1 }), 'Date must be after February 7 2020')
|
||
|
.max(add(new Date(), { days: 1 }), 'Date cannot be in the future')
|
||
|
.required('Date is required'),
|
||
|
notes: Yup.string().optional(),
|
||
|
attribution: Yup.boolean().optional(),
|
||
|
files: Yup.array()
|
||
|
.of(
|
||
|
Yup.object().shape({
|
||
|
key: Yup.string().required('key is required'),
|
||
|
uploadId: Yup.string().required('uploadId is required')
|
||
|
}),
|
||
|
)
|
||
|
.min(1, 'At least one file is required'),
|
||
|
});
|
||
|
|
||
|
|
||
|
|
||
|
export default function UploadForm({ vtubers }: IUploadFormProps) {
|
||
|
const searchParams = useSearchParams();
|
||
|
const cuid = searchParams.get('cuid');
|
||
|
|
||
|
const { authData } = useAuth();
|
||
|
|
||
|
const uppy = new Uppy(
|
||
|
{
|
||
|
autoProceed: true,
|
||
|
debug: true,
|
||
|
logger: {
|
||
|
debug: console.info,
|
||
|
warn: console.log,
|
||
|
error: console.error
|
||
|
},
|
||
|
}
|
||
|
)
|
||
|
.use(RemoteSources, {
|
||
|
companionUrl,
|
||
|
sources: [
|
||
|
'GoogleDrive',
|
||
|
'Dropbox',
|
||
|
'Url'
|
||
|
]
|
||
|
})
|
||
|
.use(AwsS3, {
|
||
|
companionUrl,
|
||
|
shouldUseMultipart: true,
|
||
|
abortMultipartUpload: () => {}, // @see https://github.com/transloadit/uppy/issues/1197#issuecomment-491756118
|
||
|
companionHeaders: {
|
||
|
'authorization': `Bearer ${authData?.accessToken}`
|
||
|
}
|
||
|
})
|
||
|
|
||
|
|
||
|
|
||
|
const formOptions = {
|
||
|
resolver: yupResolver(validationSchema),
|
||
|
mode: 'onChange' as keyof ValidationMode,
|
||
|
};
|
||
|
const {
|
||
|
register,
|
||
|
handleSubmit,
|
||
|
setError,
|
||
|
clearErrors,
|
||
|
formState: {
|
||
|
errors,
|
||
|
isValid,
|
||
|
isSubmitted,
|
||
|
isSubmitSuccessful,
|
||
|
isSubmitting
|
||
|
},
|
||
|
setValue,
|
||
|
watch,
|
||
|
reset
|
||
|
} = useForm(formOptions);
|
||
|
|
||
|
|
||
|
// useEffect(() => {
|
||
|
// if (!cuid) return;
|
||
|
// (async () => {
|
||
|
// console.log('query')
|
||
|
// const query = qs.stringify({
|
||
|
// filters: {
|
||
|
// cuid: {
|
||
|
// '$eq': cuid
|
||
|
// }
|
||
|
// }
|
||
|
// });
|
||
|
// const res = await fetch(`${process.env.NEXT_PUBLIC_STRAPI_URL}/api/streams?${query}`);
|
||
|
// if (!res.ok) return;
|
||
|
// const matchingStream = (await res.json()).data as IStream;
|
||
|
// console.log(matchingStream);
|
||
|
// setValue('vtuber', matchingStream.attributes.vtuber.data.id);
|
||
|
// })();
|
||
|
// }, [cuid]);
|
||
|
|
||
|
// setValue('streamCuid', cuid||'');
|
||
|
const files = watch('files');
|
||
|
|
||
|
|
||
|
|
||
|
async function createUSC(data: IFormSchema) {
|
||
|
try {
|
||
|
const res = await fetch(`${process.env.NEXT_PUBLIC_STRAPI_URL}/api/user-submitted-contents/createFromUppy`, {
|
||
|
method: 'POST',
|
||
|
headers: {
|
||
|
'authorization': `Bearer ${authData?.accessToken}`,
|
||
|
'content-type': 'application/json',
|
||
|
'accept': 'application/json'
|
||
|
},
|
||
|
body: JSON.stringify({
|
||
|
data: {
|
||
|
files: data.files,
|
||
|
attribution: data.attribution,
|
||
|
notes: data.notes,
|
||
|
vtuber: data.vtuber,
|
||
|
date: data.date,
|
||
|
streamCuid: cuid
|
||
|
}
|
||
|
})
|
||
|
});
|
||
|
|
||
|
if (!res.ok) {
|
||
|
console.error('failed to fetch /api/user-submitted-contents/createFromUppy');
|
||
|
const body = await res.json();
|
||
|
const error = body.error;
|
||
|
toast.error(`${error.type} ${error.message}`, { theme: 'dark' });
|
||
|
|
||
|
setError('root.serverError', {
|
||
|
type: error.type,
|
||
|
message: error.message
|
||
|
})
|
||
|
}
|
||
|
} catch (e) {
|
||
|
if (e instanceof Error) {
|
||
|
toast.error(`${e.message}`, { theme: 'dark' });
|
||
|
setError('root.serverError', {
|
||
|
type: "remote",
|
||
|
message: e.message,
|
||
|
});
|
||
|
} else {
|
||
|
toast.error(`Something went wrong. Please try again.`, { theme: 'dark' });
|
||
|
setError('root.serverError', {
|
||
|
type: 'remote',
|
||
|
message: 'Something went wrong. Please try again.'
|
||
|
})
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
|
||
|
uppy.on('complete', async (result: any) => {
|
||
|
for (const s of result.successful) {
|
||
|
if (!s?.s3Multipart) {
|
||
|
const m = 'file was missing s3Multipart'
|
||
|
toast.error(`${m}`, { theme: 'dark' });
|
||
|
setError('root.serverError', {
|
||
|
type: 'remote',
|
||
|
message: m
|
||
|
})
|
||
|
throw new Error(m)
|
||
|
}
|
||
|
}
|
||
|
console.log('uppy complete! ')
|
||
|
console.log(result)
|
||
|
toast.success(`upload complete`);
|
||
|
let files = result.successful.map((f: any) => ({ key: f.s3Multipart.key, uploadId: f.s3Multipart.uploadId }));
|
||
|
setValue('files', files);
|
||
|
});
|
||
|
|
||
|
|
||
|
return (
|
||
|
<>
|
||
|
|
||
|
<div className='section'>
|
||
|
<h2 className='title is-2'>Upload VOD</h2>
|
||
|
|
||
|
<p className="mb-5"><i>Together we can archive all lewdtuber livestreams!</i></p>
|
||
|
|
||
|
{(!authData?.accessToken)
|
||
|
?
|
||
|
<>
|
||
|
<aside className='notification is-danger'><p>Please log in to upload VODs</p></aside>
|
||
|
<LoginButton />
|
||
|
</>
|
||
|
: (
|
||
|
|
||
|
|
||
|
|
||
|
<div className='columns is-multiline'>
|
||
|
<form id="vod-details" onSubmit={handleSubmit(createUSC)}>
|
||
|
|
||
|
|
||
|
{(!isSubmitSuccessful) && <div className='column is-full'>
|
||
|
<section className="hero is-info mb-3">
|
||
|
<div className="hero-body">
|
||
|
<p className="title">
|
||
|
Step 1
|
||
|
</p>
|
||
|
<p className="subtitle">
|
||
|
Upload the file
|
||
|
</p>
|
||
|
</div>
|
||
|
</section>
|
||
|
<section className="section mb-5">
|
||
|
<Dashboard
|
||
|
uppy={uppy}
|
||
|
theme='dark'
|
||
|
proudlyDisplayPoweredByUppy={true}
|
||
|
showProgressDetails={true}
|
||
|
/>
|
||
|
|
||
|
{/*
|
||
|
Here is how we upload the files to the server.
|
||
|
From uppy, we get a list of files.
|
||
|
we add the files to a hidden input box.
|
||
|
the input box is part of the form which gets POSTed.
|
||
|
*/}
|
||
|
<input
|
||
|
required
|
||
|
hidden={true}
|
||
|
style={{ display: 'block' }}
|
||
|
className="input" type="text"
|
||
|
{...register('files')}
|
||
|
></input>
|
||
|
|
||
|
<button className="button" onClick={() => { setValue('files', [
|
||
|
{
|
||
|
"key": "4b4063a2-6b57-48f1-8565-a12ddce473e9-E1tB0KoUcAYJTni.jpg",
|
||
|
"uploadId": "4_z7d53875ff1c32a1983d30b18_f2129582707239923_d20240708_m003328_c000_v0001086_t0006_u01720398808368"
|
||
|
}
|
||
|
]); }}>(Debug) Add a list of files</button>
|
||
|
|
||
|
|
||
|
{errors.files && <p className="help is-danger">{errors.files.message?.toString()}</p>}
|
||
|
|
||
|
</section>
|
||
|
</div>}
|
||
|
|
||
|
{(!isSubmitSuccessful) && <div className='column is-full '>
|
||
|
{/* {(!cuid) && <aside className='notification is-info'>Hint: Some of these fields are filled out automatically when uploading from a <Link href="/streams">stream</Link> page.</aside>} */}
|
||
|
|
||
|
<section className="hero is-info mb-3">
|
||
|
<div className="hero-body">
|
||
|
<p className="title">
|
||
|
Step 2
|
||
|
</p>
|
||
|
<p className="subtitle">
|
||
|
Tell us about the VOD
|
||
|
</p>
|
||
|
</div>
|
||
|
</section>
|
||
|
|
||
|
<section className="section">
|
||
|
|
||
|
|
||
|
|
||
|
{/* <input
|
||
|
required
|
||
|
// hidden={false}
|
||
|
// style={{ display: 'none' }}
|
||
|
className="input" type="text"
|
||
|
{...register('streamCuid')}
|
||
|
></input> */}
|
||
|
|
||
|
|
||
|
<div className="field">
|
||
|
<label className="label">VTuber</label>
|
||
|
<div className="select">
|
||
|
<select
|
||
|
required
|
||
|
// value={vtuber}
|
||
|
// onChange={(evt) => setVtuber(parseInt(evt.target.value))}
|
||
|
{...register('vtuber')}
|
||
|
>
|
||
|
{vtubers.map((vtuber: IVtuber) => (
|
||
|
<option key={vtuber.id} value={vtuber.id}>{vtuber.attributes.displayName}</option>
|
||
|
))}
|
||
|
</select>
|
||
|
</div>
|
||
|
<p className="help is-info">Choose the VTuber this VOD belongs to. (More VTubers will be added when storage/bandwidth funding is secured.)</p>
|
||
|
{errors.vtuber && <p className="help is-danger">vtuber error</p>}
|
||
|
|
||
|
</div>
|
||
|
|
||
|
<div className="field">
|
||
|
<label className="label">Stream Date</label>
|
||
|
<input
|
||
|
required
|
||
|
className="input" type="date"
|
||
|
{...register('date')}
|
||
|
// onChange={(evt) => setDate(evt.target.value)}
|
||
|
></input>
|
||
|
<p className="help is-info">The date when the VOD was originally streamed.</p>
|
||
|
{errors.date && <p className="help is-danger">{errors.date.message?.toString()}</p>}
|
||
|
|
||
|
</div>
|
||
|
|
||
|
<div className="field">
|
||
|
<label className="label">Notes</label>
|
||
|
<textarea
|
||
|
className="textarea"
|
||
|
placeholder="e.g. Missing first 10 minutes of stream"
|
||
|
// onChange={(evt) => setNote(evt.target.value)}
|
||
|
{...register('notes')}
|
||
|
></textarea>
|
||
|
<p className="help is-info">If there are any issues with the VOD, put a note here. If there are no VOD issues, leave this field blank.</p>
|
||
|
</div>
|
||
|
|
||
|
<div className="field">
|
||
|
<label className="label">Attribution</label>
|
||
|
<label className="checkbox">
|
||
|
<input
|
||
|
type="checkbox"
|
||
|
// onChange={(evt) => setAttribution(evt.target.checked)}
|
||
|
{...register('attribution')}
|
||
|
/>
|
||
|
<span className={`ml-2 ${styles.noselect}`}>Credit {authData.user?.username} for the upload.</span>
|
||
|
<p className="help is-info">Check this box if you want your username displayed on the website. Thank you for uploading!</p>
|
||
|
</label>
|
||
|
</div>
|
||
|
|
||
|
</section>
|
||
|
|
||
|
</div>}
|
||
|
|
||
|
|
||
|
<div className="column is-full">
|
||
|
<section className="hero is-info">
|
||
|
<div className="hero-body">
|
||
|
<p className="title">
|
||
|
Step 3
|
||
|
</p>
|
||
|
<p className="subtitle">
|
||
|
Send the form
|
||
|
</p>
|
||
|
</div>
|
||
|
</section>
|
||
|
<section className="section">
|
||
|
|
||
|
|
||
|
|
||
|
|
||
|
{errors.root?.serverError && (
|
||
|
<div className="notification">
|
||
|
<button className="delete" onClick={() => clearErrors()}></button>
|
||
|
<ErrorMessage name="root" errors={errors} ></ErrorMessage>
|
||
|
</div>
|
||
|
)}
|
||
|
|
||
|
|
||
|
|
||
|
{!isSubmitSuccessful && (
|
||
|
<button className="button is-primary is-large mt-5">
|
||
|
<span className="icon is-small">
|
||
|
<FontAwesomeIcon icon={faPaperPlane}></FontAwesomeIcon>
|
||
|
</span>
|
||
|
<span>Send</span>
|
||
|
</button>
|
||
|
)}
|
||
|
|
||
|
{isSubmitting && (
|
||
|
<p>
|
||
|
<FontAwesomeIcon className="mt-5 fa-spin-pulse" icon={faSpinner} ></FontAwesomeIcon>
|
||
|
</p>
|
||
|
)}
|
||
|
{isSubmitSuccessful && (
|
||
|
<>
|
||
|
<aside className="notification mt-5 is-success">Thank you for uploading! A moderator will review the VOD before being published.</aside>
|
||
|
<button onClick={() => {
|
||
|
reset(); // reset form
|
||
|
const files = uppy.getFiles()
|
||
|
for (const file of files) {
|
||
|
uppy.removeFile(file.id); // reset uppy
|
||
|
}
|
||
|
}} className="button is-primary">
|
||
|
<span className="icon is-small">
|
||
|
<FontAwesomeIcon icon={faEraser}></FontAwesomeIcon>
|
||
|
</span>
|
||
|
<span>Reset form</span>
|
||
|
</button>
|
||
|
</>
|
||
|
)}
|
||
|
|
||
|
|
||
|
|
||
|
</section>
|
||
|
</div>
|
||
|
|
||
|
</form>
|
||
|
</div>
|
||
|
|
||
|
|
||
|
)
|
||
|
}
|
||
|
|
||
|
</div>
|
||
|
|
||
|
</>
|
||
|
)
|
||
|
|
||
|
}
|