From c5134fff1c15c9a19bbd351f25b5c4306b1cfe08 Mon Sep 17 00:00:00 2001 From: tomast1337 Date: Thu, 16 May 2024 19:48:24 -0300 Subject: [PATCH 001/270] Fix color substring bug in thumbnail generation --- shared/features/thumbnail/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/features/thumbnail/index.ts b/shared/features/thumbnail/index.ts index abcf4b1a..4d6b6a11 100644 --- a/shared/features/thumbnail/index.ts +++ b/shared/features/thumbnail/index.ts @@ -150,7 +150,7 @@ function getKeyText(key: number): string { function getLuma(color: string): number { // source: https://stackoverflow.com/a/12043228/9045426 - const c = color.substring(1); // strip # + const c = color?.substring(1) || ''; // strip # const rgb = parseInt(c, 16); // convert rrggbb to decimal const r = (rgb >> 16) & 0xff; // extract red const g = (rgb >> 8) & 0xff; // extract green From da0978831f5e355b7c1bfa6c7e8d3b809f3a004d Mon Sep 17 00:00:00 2001 From: tomast1337 Date: Sat, 18 May 2024 21:22:18 -0300 Subject: [PATCH 002/270] Add Song and Edit Song context providers --- .../client/context/EditSong.context.tsx | 76 ++++++++ .../client/context/Song.context.tsx | 44 +++++ .../client/context/UploadSong.context.tsx | 175 ++++++++++++++++++ 3 files changed, 295 insertions(+) create mode 100644 web/src/modules/upload/components/client/context/EditSong.context.tsx create mode 100644 web/src/modules/upload/components/client/context/Song.context.tsx create mode 100644 web/src/modules/upload/components/client/context/UploadSong.context.tsx diff --git a/web/src/modules/upload/components/client/context/EditSong.context.tsx b/web/src/modules/upload/components/client/context/EditSong.context.tsx new file mode 100644 index 00000000..e91176a7 --- /dev/null +++ b/web/src/modules/upload/components/client/context/EditSong.context.tsx @@ -0,0 +1,76 @@ +'use client'; +import { Song } from '@encode42/nbs.js'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { createContext, useEffect, useState } from 'react'; +import { + FieldErrors, + UseFormRegister, + UseFormReturn, + useForm, +} from 'react-hook-form'; + +import { EditSongForm, editSongFormSchema } from '../uploadSongForm.zod'; + +export type useEditSongProviderType = { + formMethods: UseFormReturn; + submitSong: () => void; + register: UseFormRegister; + errors: FieldErrors; + song: Song | null; + sendError: string | null; + isSubmitting: boolean; +}; +export const EditSongContext = createContext( + null as unknown as useEditSongProviderType, +); +export const EditSongProvider = ({ + children, +}: { + children: React.ReactNode; +}) => { + const formMethods = useForm({ + resolver: zodResolver(editSongFormSchema), + mode: 'onBlur', + }); + + const [song, setSong] = useState(null); + const [sendError, setSendError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + const { + register, + formState: { errors }, + } = formMethods; + + const submitSong = async () => undefined; + + useEffect(() => { + if (song) { + formMethods.setValue('coverData.zoomLevel', 1); + formMethods.setValue('coverData.startTick', 0); + formMethods.setValue('coverData.startLayer', 0); + formMethods.setValue('coverData.backgroundColor', '#ffffff'); + formMethods.setValue('customInstruments', [ + 'custom1', + 'custom2', + 'custom3', + ]); + } + }, [song, formMethods]); + + return ( + + {children} + + ); +}; diff --git a/web/src/modules/upload/components/client/context/Song.context.tsx b/web/src/modules/upload/components/client/context/Song.context.tsx new file mode 100644 index 00000000..1a8c5ff2 --- /dev/null +++ b/web/src/modules/upload/components/client/context/Song.context.tsx @@ -0,0 +1,44 @@ +'use client'; + +import { useContext } from 'react'; + +import { + EditSongContext, + EditSongProvider, + useEditSongProviderType, +} from './EditSong.context'; +import { + UploadSongContext, + UploadSongProvider, + useUploadSongProviderType, +} from './UploadSong.context'; + +export const SongProvider = ({ children }: { children: React.ReactNode }) => { + return ( + + {children} + + ); +}; + +type ContextType = 'upload' | 'edit'; + +type useSongProviderType = ( + type: T, +) => T extends 'upload' ? useUploadSongProviderType : useEditSongProviderType; + +export const useSongProvider: useSongProviderType = ( + type: T, +) => { + const uploadContext = useContext(UploadSongContext); + const editContext = useContext(EditSongContext); + if (type === 'upload') { + return uploadContext as T extends 'upload' + ? useUploadSongProviderType + : useEditSongProviderType; + } else { + return editContext as T extends 'upload' + ? useUploadSongProviderType + : useEditSongProviderType; + } +}; diff --git a/web/src/modules/upload/components/client/context/UploadSong.context.tsx b/web/src/modules/upload/components/client/context/UploadSong.context.tsx new file mode 100644 index 00000000..9dd5fda5 --- /dev/null +++ b/web/src/modules/upload/components/client/context/UploadSong.context.tsx @@ -0,0 +1,175 @@ +'use client'; +import { Song, fromArrayBuffer } from '@encode42/nbs.js'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { createContext, useEffect, useState } from 'react'; +import { + FieldErrors, + UseFormRegister, + UseFormReturn, + useForm, +} from 'react-hook-form'; + +import axiosInstance from '@web/src/lib/axios'; +import { getTokenLocal } from '@web/src/lib/axios/token.utils'; + +import { UploadSongForm, uploadSongFormSchema } from '../uploadSongForm.zod'; + +export type useUploadSongProviderType = { + song: Song | null; + filename: string | null; + setFile: (file: File | null) => void; + invalidFile: boolean; + formMethods: UseFormReturn; + submitSong: () => void; + register: UseFormRegister; + errors: FieldErrors; + sendError: string | null; + isSubmitting: boolean; + isUploadComplete: boolean; + uploadedSongId: string | null; +}; +export const UploadSongContext = createContext( + null as unknown as useUploadSongProviderType, +); +export const UploadSongProvider = ({ + children, +}: { + children: React.ReactNode; +}) => { + const [song, setSong] = useState(null); + const [filename, setFilename] = useState(null); + const [invalidFile, setInvalidFile] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [sendError, setSendError] = useState(null); + const [isUploadComplete, setIsUploadComplete] = useState(false); + const [uploadedSongId, setUploadedSongId] = useState(null); + + const formMethods = useForm({ + resolver: zodResolver(uploadSongFormSchema), + mode: 'onBlur', + }); + const { + register, + formState: { errors }, + } = formMethods; + + const submitSongData = async (): Promise => { + // Get song file from state + setSendError(null); + if (!song) { + throw new Error('Song file not found'); + } + const arrayBuffer = song.toArrayBuffer(); + if (arrayBuffer.byteLength === 0) { + throw new Error('Song file is invalid'); + } + const blob = new Blob([arrayBuffer]); + + // Build form data + const formData = new FormData(); + formData.append('file', blob, filename || 'song.nbs'); + const formValues = formMethods.getValues(); + Object.entries(formValues) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + .filter(([key, _]) => key !== 'coverData' && key !== 'customInstruments') + .forEach(([key, value]) => { + formData.append(key, value.toString()); + }); + formData.append('coverData', JSON.stringify(formValues.coverData)); + formData.append( + 'customInstruments', + JSON.stringify(formValues.customInstruments), + ); + + // Get authorization token from local storage + const token = getTokenLocal(); + + // Send request + await axiosInstance + .post(`/song`, formData, { + headers: { + authorization: `Bearer ${token}`, + 'Content-Type': 'multipart/form-data', + }, + }) + .then((response) => { + const data = response.data; + const id = data.publicId as string; + setUploadedSongId(id); + setIsUploadComplete(true); + }) + .catch((error) => { + console.error('Error submitting song', error); + if (error.response) { + setSendError(error.response.data.error.file); + } else { + setSendError('An unknown error occurred while submitting the song!'); + } + return; + }); + }; + + const submitSong = async () => { + try { + setIsSubmitting(true); + await submitSongData(); + setIsUploadComplete(true); + } catch (e) { + console.log(e); // TODO: handle error + } finally { + setIsSubmitting(false); + } + }; + + const setFileHandler = async (file: File | null) => { + if (!file) return; + const song = fromArrayBuffer(await file.arrayBuffer()); + if (song.length <= 0) { + setInvalidFile(true); + setSong(null); + return; + } + setSong(song); + setFilename(file.name); + + const { name, description, originalAuthor } = song.meta; + const title = name || filename?.replace('.nbs', '') || ''; + formMethods.setValue('title', title); + formMethods.setValue('description', description); + formMethods.setValue('originalAuthor', originalAuthor); + }; + useEffect(() => { + if (song) { + formMethods.setValue('coverData.zoomLevel', 1); + formMethods.setValue('coverData.startTick', 0); + formMethods.setValue('coverData.startLayer', 0); + formMethods.setValue('coverData.backgroundColor', '#ffffff'); + formMethods.setValue('customInstruments', [ + 'custom1', + 'custom2', + 'custom3', + ]); + } + }, [song, formMethods]); + + return ( + + {children} + + ); +}; From b334f8fbc8edb5e52dde3446eaae6643c28feb87 Mon Sep 17 00:00:00 2001 From: tomast1337 Date: Sat, 18 May 2024 21:22:25 -0300 Subject: [PATCH 003/270] Refactor SongFormSchema in uploadSongForm.zod.ts --- .../components/client/uploadSongForm.zod.ts | 58 ++++++++++++------- 1 file changed, 37 insertions(+), 21 deletions(-) diff --git a/web/src/modules/upload/components/client/uploadSongForm.zod.ts b/web/src/modules/upload/components/client/uploadSongForm.zod.ts index 8e173243..f6cd15e3 100644 --- a/web/src/modules/upload/components/client/uploadSongForm.zod.ts +++ b/web/src/modules/upload/components/client/uploadSongForm.zod.ts @@ -7,9 +7,11 @@ export const coverDataSchema = zod.object({ backgroundColor: zod.string().regex(/^#[0-9a-fA-F]{6}$/), }); -const SongFormSchema = zod.object({ - allowDownload: zod.boolean(), - visibility: zod.union([zod.literal('public'), zod.literal('private')]), +export const SongFormSchema = zod.object({ + allowDownload: zod.boolean().default(false), + visibility: zod + .union([zod.literal('public'), zod.literal('private')]) + .default('public'), title: zod .string() .max(64, { @@ -24,31 +26,43 @@ const SongFormSchema = zod.object({ message: 'Original author must be less than 64 characters', }) .min(0), + artist: zod.string().min(0), description: zod.string().max(1024, { message: 'Description must be less than 1024 characters', }), coverData: coverDataSchema, customInstruments: zod.array(zod.string()), - license: zod.union([ - zod.literal('no_license'), - zod.literal('cc_by_4'), - zod.literal('public_domain'), - ]), - category: zod.union([ - zod.literal('Gaming'), - zod.literal('MoviesNTV'), - zod.literal('Anime'), - zod.literal('Vocaloid'), - zod.literal('Rock'), - zod.literal('Pop'), - zod.literal('Electronic'), - zod.literal('Ambient'), - zod.literal('Jazz'), - zod.literal('Classical'), - ]), + license: zod + .union([ + zod.literal('no_license'), + zod.literal('cc_by_4'), + zod.literal('public_domain'), + ]) + .refine( + (value) => ['no_license', 'cc_by_4', 'public_domain'].includes(value), + { + message: + "Invalid license. Must be one of 'No license', 'CC BY 4.0', 'Public domain'", + }, + ) + .default('no_license'), + category: zod + .union([ + zod.literal('Gaming'), + zod.literal('MoviesNTV'), + zod.literal('Anime'), + zod.literal('Vocaloid'), + zod.literal('Rock'), + zod.literal('Pop'), + zod.literal('Electronic'), + zod.literal('Ambient'), + zod.literal('Jazz'), + zod.literal('Classical'), + ]) + .optional(), }); -export const uploadSongFormSchema = SongFormSchema; +export const uploadSongFormSchema = SongFormSchema.extend({}); export const editSongFormSchema = SongFormSchema.extend({ id: zod.string(), @@ -57,3 +71,5 @@ export const editSongFormSchema = SongFormSchema.extend({ export type CoverData = zod.infer; export type UploadSongForm = zod.infer; + +export type EditSongForm = zod.infer; From cf1072aaf63846c77983e51746f23e2040575433 Mon Sep 17 00:00:00 2001 From: tomast1337 Date: Sat, 18 May 2024 21:22:59 -0300 Subject: [PATCH 004/270] Refactor React imports in FormElements.tsx --- web/src/modules/upload/components/client/FormElements.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/web/src/modules/upload/components/client/FormElements.tsx b/web/src/modules/upload/components/client/FormElements.tsx index 871bce93..0ee0a7bd 100644 --- a/web/src/modules/upload/components/client/FormElements.tsx +++ b/web/src/modules/upload/components/client/FormElements.tsx @@ -1,8 +1,8 @@ -import React from 'react'; +import { forwardRef } from 'react'; import { cn } from '@web/src/lib/tailwind.utils'; -export const Input = React.forwardRef< +export const Input = forwardRef< HTMLInputElement, React.InputHTMLAttributes >((props, ref) => { @@ -16,7 +16,7 @@ export const Input = React.forwardRef< }); Input.displayName = 'Input'; -export const Select = React.forwardRef< +export const Select = forwardRef< HTMLSelectElement, React.SelectHTMLAttributes >((props, ref) => { @@ -33,7 +33,7 @@ export const Select = React.forwardRef< }); Select.displayName = 'Select'; -export const Option = React.forwardRef< +export const Option = forwardRef< HTMLOptionElement, React.OptionHTMLAttributes >((props, ref) => { From b0cf0bcf03f58b46a16219facf2eaae43b3ca251 Mon Sep 17 00:00:00 2001 From: tomast1337 Date: Sat, 18 May 2024 21:23:21 -0300 Subject: [PATCH 005/270] Refactor SongSelector component to use SongProvider context --- web/src/modules/upload/components/client/SongSelector.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/modules/upload/components/client/SongSelector.tsx b/web/src/modules/upload/components/client/SongSelector.tsx index c9a3b03a..e3f20767 100644 --- a/web/src/modules/upload/components/client/SongSelector.tsx +++ b/web/src/modules/upload/components/client/SongSelector.tsx @@ -4,11 +4,11 @@ import { useCallback } from 'react'; import { useDropzone } from 'react-dropzone'; import toast from 'react-hot-toast'; -import { useUploadSongProvider } from './UploadSong.context'; +import { useSongProvider } from './context/Song.context'; import { ErrorBalloon } from '../../../shared/components/client/ErrorBalloon'; export const SongSelector = () => { - const { setFile, invalidFile } = useUploadSongProvider(); + const { setFile, invalidFile } = useSongProvider('upload'); const handleFileSelect = useCallback( (e: React.ChangeEvent) => { From cbae2bb2639636c4548ad71b35b58528d76e4fba Mon Sep 17 00:00:00 2001 From: tomast1337 Date: Sat, 18 May 2024 21:23:30 -0300 Subject: [PATCH 006/270] Delete UploadSong.context.tsx and types.ts --- .../components/client/UploadSong.context.tsx | 190 ------------------ web/src/modules/upload/types.ts | 10 - 2 files changed, 200 deletions(-) delete mode 100644 web/src/modules/upload/components/client/UploadSong.context.tsx delete mode 100644 web/src/modules/upload/types.ts diff --git a/web/src/modules/upload/components/client/UploadSong.context.tsx b/web/src/modules/upload/components/client/UploadSong.context.tsx deleted file mode 100644 index 4f2074a3..00000000 --- a/web/src/modules/upload/components/client/UploadSong.context.tsx +++ /dev/null @@ -1,190 +0,0 @@ -'use client'; - -import { Song, fromArrayBuffer } from '@encode42/nbs.js'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { useRouter } from 'next/navigation'; -import { createContext, useContext, useEffect, useState } from 'react'; -import { - FieldErrors, - UseFormRegister, - UseFormReturn, - useForm, -} from 'react-hook-form'; - -import axiosInstance from '@web/src/lib/axios'; -import { getTokenLocal } from '@web/src/lib/axios/token.utils'; - -import { SongFormSchema } from './uploadSongForm.zod'; -import { UploadSongForm } from '../../types'; - -type UploadSongContextType = { - song: Song | null; - filename: string | null; - setFile: (file: File | null) => void; - invalidFile: boolean; - formMethods: UseFormReturn; - submitSong: () => void; - register: UseFormRegister; - errors: FieldErrors; - sendError: string | null; - isSubmitting: boolean; - isUploadComplete: boolean; - uploadedSongId: string | null; -}; - -const UploadSongContext = createContext( - null as unknown as UploadSongContextType, -); - -export const UploadSongProvider = ({ - children, -}: { - children: React.ReactNode; -}) => { - const router = useRouter(); - const [song, setSong] = useState(null); - const [filename, setFilename] = useState(null); - const [invalidFile, setInvalidFile] = useState(false); - const [isSubmitting, setIsSubmitting] = useState(false); - const [sendError, setSendError] = useState(null); - const [isUploadComplete, setIsUploadComplete] = useState(false); - const [uploadedSongId, setUploadedSongId] = useState(null); - - const formMethods = useForm({ - resolver: zodResolver(SongFormSchema), - mode: 'onBlur', - }); - const { - register, - formState: { errors }, - } = formMethods; - - const submitSongData = async (): Promise => { - // Get song file from state - setSendError(null); - if (!song) { - throw new Error('Song file not found'); - } - const arrayBuffer = song.toArrayBuffer(); - if (arrayBuffer.byteLength === 0) { - throw new Error('Song file is invalid'); - } - const blob = new Blob([arrayBuffer]); - - // Build form data - const formData = new FormData(); - formData.append('file', blob, filename || 'song.nbs'); - const formValues = formMethods.getValues(); - Object.entries(formValues) - .filter(([key, _]) => key !== 'coverData' && key !== 'customInstruments') - .forEach(([key, value]) => { - formData.append(key, value.toString()); - }); - formData.append('coverData', JSON.stringify(formValues.coverData)); - formData.append( - 'customInstruments', - JSON.stringify(formValues.customInstruments), - ); - - // Get authorization token from local storage - const token = getTokenLocal(); - - // Send request - await axiosInstance - .post(`/song`, formData, { - headers: { - authorization: `Bearer ${token}`, - 'Content-Type': 'multipart/form-data', - }, - }) - .then((response) => { - const data = response.data; - const id = data.publicId as string; - setUploadedSongId(id); - setIsUploadComplete(true); - }) - .catch((error) => { - console.error('Error submitting song', error); - if (error.response) { - setSendError(error.response.data.error.file); - } else { - setSendError('An unknown error occurred while submitting the song!'); - } - return; - }); - }; - - const submitSong = async () => { - try { - setIsSubmitting(true); - await submitSongData(); - setIsUploadComplete(true); - } catch (e) { - console.log(e); // TODO: handle error - } finally { - setIsSubmitting(false); - } - }; - - const setFileHandler = async (file: File | null) => { - if (!file) return; - const song = fromArrayBuffer(await file.arrayBuffer()); - if (song.length <= 0) { - setInvalidFile(true); - setSong(null); - return; - } - setSong(song); - setFilename(file.name); - - const { name, description, originalAuthor } = song.meta; - const title = name || filename?.replace('.nbs', '') || ''; - formMethods.setValue('title', title); - formMethods.setValue('description', description); - formMethods.setValue('originalAuthor', originalAuthor); - }; - useEffect(() => { - if (song) { - formMethods.setValue('coverData.zoomLevel', 1); - formMethods.setValue('coverData.startTick', 0); - formMethods.setValue('coverData.startLayer', 0); - formMethods.setValue('coverData.backgroundColor', '#ffffff'); - formMethods.setValue('customInstruments', [ - 'custom1', - 'custom2', - 'custom3', - ]); - } - }, [song, formMethods]); - - return ( - - {children} - - ); -}; - -export const useUploadSongProvider = () => { - const context = useContext(UploadSongContext); - if (context === undefined || context === null) { - throw new Error( - 'useUploadSongProvider must be used within a UploadSongProvider', - ); - } - return context; -}; diff --git a/web/src/modules/upload/types.ts b/web/src/modules/upload/types.ts deleted file mode 100644 index 13850099..00000000 --- a/web/src/modules/upload/types.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { z as zod } from 'zod'; - -import { - coverDataSchema, - uploadSongFormSchema, -} from './components/client/uploadSongForm.zod'; - -export type CoverData = zod.infer; - -export type UploadSongForm = zod.infer; From 328f9042e7c8278491a83da818a3a2b836b29ed9 Mon Sep 17 00:00:00 2001 From: tomast1337 Date: Sat, 18 May 2024 21:23:55 -0300 Subject: [PATCH 007/270] Refactor UploadSong component and context imports --- .../modules/upload/components/client/UploadSong.tsx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/web/src/modules/upload/components/client/UploadSong.tsx b/web/src/modules/upload/components/client/UploadSong.tsx index 3b89a179..89692ded 100644 --- a/web/src/modules/upload/components/client/UploadSong.tsx +++ b/web/src/modules/upload/components/client/UploadSong.tsx @@ -6,14 +6,11 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { SongSelector } from './SongSelector'; import { SongUploadForm } from './SongUploadForm'; import UploadCompleteModal from './UploadCompleteModal'; -import { - UploadSongProvider, - useUploadSongProvider, -} from './UploadSong.context'; +import { SongProvider, useSongProvider } from './context/Song.context'; const UploadSong = ({ defaultAuthorName }: { defaultAuthorName: string }) => { const { song, filename, isUploadComplete, uploadedSongId } = - useUploadSongProvider(); + useSongProvider('upload'); return ( <> @@ -61,8 +58,8 @@ export const UploadSongPage = ({ defaultAuthorName: string; }) => { return ( - + - + ); }; From 2b8d479c6c9b5bc24bd7c91d350a2d5b3b092ba6 Mon Sep 17 00:00:00 2001 From: tomast1337 Date: Sat, 18 May 2024 21:24:06 -0300 Subject: [PATCH 008/270] Refactor InstrumentPicker component to use Song context --- .../upload/components/client/InstrumentPicker.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/web/src/modules/upload/components/client/InstrumentPicker.tsx b/web/src/modules/upload/components/client/InstrumentPicker.tsx index f7fdec05..ebfbdff8 100644 --- a/web/src/modules/upload/components/client/InstrumentPicker.tsx +++ b/web/src/modules/upload/components/client/InstrumentPicker.tsx @@ -1,7 +1,7 @@ import { cn } from '@web/src/lib/tailwind.utils'; +import { useSongProvider } from './context/Song.context'; import { Option, Select } from './FormElements'; -import { useUploadSongProvider } from './UploadSong.context'; const sounds = [ { name: 'sound1' }, @@ -49,8 +49,8 @@ const InstrumentTableCell = ({ ); }; -const InstrumentTable = () => { - const { song } = useUploadSongProvider(); +const InstrumentTable = ({ type }: { type: 'upload' | 'edit' }) => { + const { song } = useSongProvider(type); if (!song) return null; const instruments = song.instruments.loaded.filter( @@ -114,8 +114,8 @@ const InstrumentTable = () => { ); }; -const InstrumentPicker = () => { - const { song } = useUploadSongProvider(); +const InstrumentPicker = ({ type }: { type: 'upload' | 'edit' }) => { + const { song } = useSongProvider(type); if (!song) return null; // TODO: this is re-running when the thumbnail sliders are changed. Why? @@ -127,7 +127,10 @@ const InstrumentPicker = () => { return customInstrumentCount === 0 ? (

Sounds pretty vanilla!

) : ( - + ); }; From 5cf1756a001126f9124636ec96bf996939445f06 Mon Sep 17 00:00:00 2001 From: tomast1337 Date: Sat, 18 May 2024 21:32:26 -0300 Subject: [PATCH 009/270] Refactor useSongProvider function signature --- .../upload/components/client/context/Song.context.tsx | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/web/src/modules/upload/components/client/context/Song.context.tsx b/web/src/modules/upload/components/client/context/Song.context.tsx index 1a8c5ff2..a05e3138 100644 --- a/web/src/modules/upload/components/client/context/Song.context.tsx +++ b/web/src/modules/upload/components/client/context/Song.context.tsx @@ -22,14 +22,7 @@ export const SongProvider = ({ children }: { children: React.ReactNode }) => { }; type ContextType = 'upload' | 'edit'; - -type useSongProviderType = ( - type: T, -) => T extends 'upload' ? useUploadSongProviderType : useEditSongProviderType; - -export const useSongProvider: useSongProviderType = ( - type: T, -) => { +export const useSongProvider = (type: T) => { const uploadContext = useContext(UploadSongContext); const editContext = useContext(EditSongContext); if (type === 'upload') { From 405c50af000ffb203305549bdda4e3a92d43a17a Mon Sep 17 00:00:00 2001 From: tomast1337 Date: Sat, 18 May 2024 21:32:38 -0300 Subject: [PATCH 010/270] Refactor ThumbnailRendererCanvas component --- .../components/client/ThumbnailRenderer.tsx | 80 +++++++++++++++++-- 1 file changed, 72 insertions(+), 8 deletions(-) diff --git a/web/src/modules/upload/components/client/ThumbnailRenderer.tsx b/web/src/modules/upload/components/client/ThumbnailRenderer.tsx index 55ffdf63..ff5bd485 100644 --- a/web/src/modules/upload/components/client/ThumbnailRenderer.tsx +++ b/web/src/modules/upload/components/client/ThumbnailRenderer.tsx @@ -1,26 +1,32 @@ import type { Note } from '@shared/features/thumbnail'; import { drawNotesOffscreen, swap } from '@shared/features/thumbnail'; import { useEffect, useRef, useState } from 'react'; +import { UseFormReturn } from 'react-hook-form'; + +import { UploadSongForm } from './uploadSongForm.zod'; type ThumbnailRendererCanvasProps = { notes: Note[]; - zoomLevel: number; - startTick: number; - startLayer: number; - backgroundColor: string; + formMethods: UseFormReturn; }; export const ThumbnailRendererCanvas = ({ notes, - zoomLevel, - startTick, - startLayer, - backgroundColor, + formMethods, }: ThumbnailRendererCanvasProps) => { const canvasRef = useRef(null); const drawRequest = useRef(null); const [loading, setLoading] = useState(true); + const [zoomLevel, startTick, startLayer, backgroundColor] = formMethods.watch( + [ + 'coverData.zoomLevel', + 'coverData.startTick', + 'coverData.startLayer', + 'coverData.backgroundColor', + ], + ); + useEffect(() => { const canvas = canvasRef.current as HTMLCanvasElement | null; if (!canvas) return; @@ -28,6 +34,64 @@ export const ThumbnailRendererCanvas = ({ // Set canvas size to match the container size canvas.width = canvas.offsetWidth; canvas.height = canvas.width / (1280 / 768); + /* + // on mouse scroll event + canvas.addEventListener('wheel', (e) => { + const delta = e.deltaY; + // do something with delta + const maxZoom = 5; + const minZoom = 1; + const zoomSpeed = 0.1; + const newZoom = zoomLevel + delta * zoomSpeed; + formMethods.setValue( + 'coverData.zoomLevel', + Math.min(Math.max(newZoom, minZoom), maxZoom), + ); + }); + + // on mouse drag event + canvas.addEventListener('mousedown', (e) => { + const mouse = { + x: e.clientX, + y: e.clientY, + }; + + const minLayer = 0; + const maxLayer = 3; + + const minTick = 0; + const maxTick = 1000; + + canvas.addEventListener('mousemove', (e) => { + const dx = e.clientX - mouse.x; + const dy = e.clientY - mouse.y; + mouse.x = e.clientX; + mouse.y = e.clientY; + // do something with dx and dy + + const newTick = startTick - dy; + const newLayer = startLayer + dx; + + formMethods.setValue( + 'coverData.startTick', + Math.min(Math.max(newTick, minTick), maxTick), + ); + + formMethods.setValue( + 'coverData.startLayer', + Math.min(Math.max(newLayer, minLayer), maxLayer), + ); + }); + }); + canvas.addEventListener('mouseup', () => { + canvas.removeEventListener('mousemove', () => undefined); + }); + return () => { + canvas.removeEventListener('mousedown', () => undefined); + canvas.removeEventListener('mouseup', () => undefined); + canvas.removeEventListener('wheel', () => undefined); + }; + */ }, []); useEffect(() => { From fe13d4ab485ac5d1079c1c8b32f93211fc4e8771 Mon Sep 17 00:00:00 2001 From: tomast1337 Date: Sat, 18 May 2024 21:32:42 -0300 Subject: [PATCH 011/270] Add SongForm component for uploading and editing songs --- .../upload/components/client/SongForm.tsx | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 web/src/modules/upload/components/client/SongForm.tsx diff --git a/web/src/modules/upload/components/client/SongForm.tsx b/web/src/modules/upload/components/client/SongForm.tsx new file mode 100644 index 00000000..d474cce0 --- /dev/null +++ b/web/src/modules/upload/components/client/SongForm.tsx @@ -0,0 +1,187 @@ +import { useSongProvider } from './context/Song.context'; +import { Input, Option, Select } from './FormElements'; +import InstrumentPicker from './InstrumentPicker'; +import { SongThumbnailInput } from './SongThumbnailInput'; +import { ErrorBalloon } from '../../../shared/components/client/ErrorBalloon'; + +export const SongForm = ({ type }: { type: 'upload' | 'edit' }) => { + const useSongProviderData = useSongProvider(type) as + | useUploadSongProviderType + | useEditSongProviderType; + const { sendError, errors, submitSong, song, isSubmitting } = + useSongProviderData; + const formMethods = useSongProviderData.formMethods; + return ( + <> +
{ + submitSong(); + })} + > + {sendError && ( + + )} +
+ {/* Title */} +
+ + + + +
+ + {/* Description */} +
+ + + + + +
+ + {/* Author */} +
+
+ + +
+
+ + +

+ {"(Leave blank if it's an original song)"} +

+
+
+ + {/* Genre */} +
+
+ + + + + +
+
+ + {/* Thumbnail */} +
+

Thumbnail

+
+ {song && } +
+
+ + {/* Visibility */} +
+
+ + + + + +
+
+ + + +
+
+ +
+ +
+ +
+
+ + {/* Allow download */} +
+ + + +
+ +
+ +
+ {/* Uploading label */} + {isSubmitting && ( +
+ +

+ {type === 'upload' ? 'Uploading song...' : 'Updating song...'} +

+
+ )} + + {/* Upload button */} + +
+
+ + + ); +}; From 5c9f62343a405a34aaba808995cb801bd57b4f2e Mon Sep 17 00:00:00 2001 From: tomast1337 Date: Sat, 18 May 2024 21:32:46 -0300 Subject: [PATCH 012/270] Refactor SongUploadForm component --- .../components/client/SongUploadForm.tsx | 212 +----------------- 1 file changed, 10 insertions(+), 202 deletions(-) diff --git a/web/src/modules/upload/components/client/SongUploadForm.tsx b/web/src/modules/upload/components/client/SongUploadForm.tsx index 4b521eaf..da0fe12b 100644 --- a/web/src/modules/upload/components/client/SongUploadForm.tsx +++ b/web/src/modules/upload/components/client/SongUploadForm.tsx @@ -1,212 +1,20 @@ -import { Controller } from 'react-hook-form'; +import { useEffect } from 'react'; -import { Input, Option, Select } from './FormElements'; -import InstrumentPicker from './InstrumentPicker'; -import { SongThumbnailInput } from './SongThumbnailInput'; -import { useUploadSongProvider } from './UploadSong.context'; -import { ErrorBalloon } from '../../../shared/components/client/ErrorBalloon'; +import { useSongProvider } from './context/Song.context'; +import { useUploadSongProviderType } from './context/UploadSong.context'; +import { SongForm } from './SongForm'; export const SongUploadForm = ({ defaultAuthorName, }: { defaultAuthorName: string; }) => { - const { - formMethods, - sendError, - errors, - register, - submitSong, - song, - isSubmitting, - } = useUploadSongProvider(); + const type = 'upload'; + const { formMethods } = useSongProvider(type) as useUploadSongProviderType; - const { control } = formMethods; + useEffect(() => { + formMethods.setValue('artist', defaultAuthorName); + }, [defaultAuthorName]); - return ( -
{ - submitSong(); - }, - // () => console.log(sendError, errors, formMethods), // Use for debugging form - )} - > - {sendError && ( - - )} -
- {/* Title */} -
- - {/* - - */} - } - name='title' - control={control} - defaultValue='' - /> - -
- - {/* Description */} -
- - - -
- - {/* Author */} -
-
- - -
-
- - -

- {"(Leave blank if it's an original song)"} -

-
-
- - {/* Genre */} -
-
- - -
-
- - {/* Thumbnail */} -
-

Thumbnail

-
- {song && } -
-
- - {/* Visibility */} -
-
- - - -
-
- - - -
-
- -
- -
- -
-
- - {/* Allow download */} -
- - - -
- -
- -
- {/* Uploading label */} - {isSubmitting && ( -
- -

Uploading song...

-
- )} - - {/* Upload button */} - -
-
- - ); + return ; }; From 1c45269e71addaa621347cfcca25689ef83191c3 Mon Sep 17 00:00:00 2001 From: tomast1337 Date: Sat, 18 May 2024 21:32:51 -0300 Subject: [PATCH 013/270] Refactor SongThumbnailInput component --- .../components/client/SongThumbnailInput.tsx | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/web/src/modules/upload/components/client/SongThumbnailInput.tsx b/web/src/modules/upload/components/client/SongThumbnailInput.tsx index 39c9b602..6e076c92 100644 --- a/web/src/modules/upload/components/client/SongThumbnailInput.tsx +++ b/web/src/modules/upload/components/client/SongThumbnailInput.tsx @@ -1,8 +1,8 @@ import { bgColors, getThumbnailNotes } from '@shared/features/thumbnail'; import { useMemo } from 'react'; +import { useSongProvider } from './context/Song.context'; import { ThumbnailRendererCanvas } from './ThumbnailRenderer'; -import { useUploadSongProvider } from './UploadSong.context'; const ColorButton = ({ color, @@ -20,8 +20,8 @@ const ColorButton = ({ /> ); -export const SongThumbnailInput = () => { - const { song, register, formMethods } = useUploadSongProvider(); +export const SongThumbnailInput = ({ type }: { type: 'upload' | 'edit' }) => { + const { song, register, formMethods } = useSongProvider(type); const [zoomLevel, startTick, startLayer, backgroundColor] = formMethods.watch( [ 'coverData.zoomLevel', @@ -99,13 +99,7 @@ export const SongThumbnailInput = () => {
{startLayer}
- + {/* Background Color */}
From 746675f2aaa8d7d243ee3e0389f9b8dc621f642b Mon Sep 17 00:00:00 2001 From: tomast1337 Date: Tue, 21 May 2024 21:44:45 -0300 Subject: [PATCH 014/270] Refactor song DTOs and Song Upload methods Co-authored-by: Bernardo Costa --- server/src/song/dto/SongPreview.dto.ts | 6 +- server/src/song/dto/SongView.dto.ts | 4 +- .../src/song/dto/UploadSongResponseDto.dto.ts | 72 +++++++++++++++++++ server/src/song/entity/song.entity.ts | 7 +- server/src/song/song.controller.ts | 5 +- server/src/song/song.service.ts | 40 +++++++---- 6 files changed, 111 insertions(+), 23 deletions(-) create mode 100644 server/src/song/dto/UploadSongResponseDto.dto.ts diff --git a/server/src/song/dto/SongPreview.dto.ts b/server/src/song/dto/SongPreview.dto.ts index cd54f9f4..3fe9c601 100644 --- a/server/src/song/dto/SongPreview.dto.ts +++ b/server/src/song/dto/SongPreview.dto.ts @@ -6,7 +6,7 @@ import { MaxLength, } from 'class-validator'; -import { SongDocument } from '../entity/song.entity'; +import { SongWithUser } from '../entity/song.entity'; type SongPreviewUploader = { username: string; @@ -63,10 +63,10 @@ export class SongPreviewDto { Object.assign(this, partial); } - public static fromSongDocument(song: SongDocument): SongPreviewDto { + public static fromSongDocument(song: SongWithUser): SongPreviewDto { return new SongPreviewDto({ publicId: song.publicId, - uploader: song.uploader as unknown as SongPreviewUploader, + uploader: song.uploader, title: song.title, originalAuthor: song.originalAuthor, duration: song.duration, diff --git a/server/src/song/dto/SongView.dto.ts b/server/src/song/dto/SongView.dto.ts index 30c17310..54c52722 100644 --- a/server/src/song/dto/SongView.dto.ts +++ b/server/src/song/dto/SongView.dto.ts @@ -1,12 +1,12 @@ import { SongDocument } from '../entity/song.entity'; -type SongViewUploader = { +export type SongViewUploader = { username: string; profileImage: string; }; export class SongViewDto { - id: string; + publicId: string; createdAt: Date; editedAt: Date; uploader: SongViewUploader; diff --git a/server/src/song/dto/UploadSongResponseDto.dto.ts b/server/src/song/dto/UploadSongResponseDto.dto.ts new file mode 100644 index 00000000..ea8385fc --- /dev/null +++ b/server/src/song/dto/UploadSongResponseDto.dto.ts @@ -0,0 +1,72 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Transform, Type } from 'class-transformer'; +import { + IsNotEmpty, + IsString, + MaxLength, + ValidateNested, +} from 'class-validator'; + +import { CoverData } from './CoverData.dto'; +import { SongViewUploader } from './SongView.dto'; +import { SongWithUser } from '../entity/song.entity'; + +export class UploadSongResponseDto { + @IsString() + @IsNotEmpty() + @ApiProperty({ + description: 'ID of the song', + example: '1234567890abcdef12345678', + }) + publicId: string; + + @IsNotEmpty() + @IsString() + @MaxLength(128) + @ApiProperty({ + description: 'Title of the song', + example: 'My Song', + }) + title: string; + + @IsString() + @MaxLength(64) + @ApiProperty({ + description: 'Original author of the song', + example: 'Myself', + }) + uploader: SongViewUploader; + + @IsNotEmpty() + @ValidateNested() + @Type(() => CoverData) + @Transform(({ value }) => JSON.parse(value)) + @ApiProperty({ + description: 'Cover data of the song', + example: CoverData.getApiExample(), + }) + thumbnailUrl: string; + + @IsNotEmpty() + duration: number; + + @IsNotEmpty() + noteCount: number; + + constructor(partial: Partial) { + Object.assign(this, partial); + } + + public static fromSongWithUserDocument( + song: SongWithUser, + ): UploadSongResponseDto { + return new UploadSongResponseDto({ + publicId: song.publicId, + title: song.title, + uploader: song.uploader, + duration: song.duration, + thumbnailUrl: song.thumbnailUrl, + noteCount: song.noteCount, + }); + } +} diff --git a/server/src/song/entity/song.entity.ts b/server/src/song/entity/song.entity.ts index e90758e0..ad6fcdbf 100644 --- a/server/src/song/entity/song.entity.ts +++ b/server/src/song/entity/song.entity.ts @@ -3,6 +3,7 @@ import { Max, Min } from 'class-validator'; import { HydratedDocument, Schema as MongooseSchema, Types } from 'mongoose'; import { CoverData } from '../dto/CoverData.dto'; +import { SongViewUploader } from '../dto/SongView.dto'; @Schema({ timestamps: true, @@ -10,7 +11,7 @@ import { CoverData } from '../dto/CoverData.dto'; toJSON: { virtuals: true, transform: (doc, ret) => { - ret.id = ret._id; + //ret.id = ret._id; delete ret._id; // TODO: hydrate uploader //if (ret.uploader) { @@ -145,3 +146,7 @@ export class Song { export const SongSchema = SchemaFactory.createForClass(Song); export type SongDocument = Song & HydratedDocument; + +export type SongWithUser = Omit & { + uploader: SongViewUploader; +}; diff --git a/server/src/song/song.controller.ts b/server/src/song/song.controller.ts index d8e728f7..d44501ad 100644 --- a/server/src/song/song.controller.ts +++ b/server/src/song/song.controller.ts @@ -30,6 +30,7 @@ import { UserDocument } from '@server/user/entity/user.entity'; import { SongPreviewDto } from './dto/SongPreview.dto'; import { SongViewDto } from './dto/SongView.dto'; import { UploadSongDto } from './dto/UploadSongDto.dto'; +import { UploadSongResponseDto } from './dto/UploadSongResponseDto.dto'; import { SongService } from './song.service'; // Handles public-facing song routes. @@ -104,7 +105,7 @@ export class SongController { @ApiConsumes('multipart/form-data') @ApiBody({ description: 'Upload Song', - type: UploadSongDto, + type: UploadSongResponseDto, }) @UseInterceptors(FileInterceptor('file', SongController.multerConfig)) @ApiOperation({ @@ -114,7 +115,7 @@ export class SongController { @UploadedFile() file: Express.Multer.File, @Body() body: UploadSongDto, @GetRequestToken() user: UserDocument | null, - ): Promise { + ): Promise { return await this.songService.processUploadedSong({ body, file, user }); } } diff --git a/server/src/song/song.service.ts b/server/src/song/song.service.ts index bf405201..9f83e860 100644 --- a/server/src/song/song.service.ts +++ b/server/src/song/song.service.ts @@ -19,7 +19,8 @@ import { SongPageDto } from './dto/SongPageDto'; import { SongPreviewDto } from './dto/SongPreview.dto'; import { SongViewDto } from './dto/SongView.dto'; import { UploadSongDto } from './dto/UploadSongDto.dto'; -import { Song as SongEntity } from './entity/song.entity'; +import { UploadSongResponseDto } from './dto/UploadSongResponseDto.dto'; +import { Song as SongEntity, SongWithUser } from './entity/song.entity'; import { generateSongId, removeNonAscii } from './song.util'; @Injectable() @@ -53,7 +54,7 @@ export class SongService { body: UploadSongDto; file: Express.Multer.File; user: UserDocument | null; - }): Promise { + }): Promise { // Is user valid? if (!user) { throw new HttpException( @@ -225,24 +226,31 @@ export class SongService { // Save song document const songDocument = await this.songModel.create(song); const createdSong = await songDocument.save(); - - return UploadSongDto.fromSongDocument(createdSong); + const populatedSong = (await createdSong.populate( + 'uploader', + 'username profileImage -_id', + )) as unknown as SongWithUser; + return UploadSongResponseDto.fromSongWithUserDocument(populatedSong); } - public async deleteSong(id: string): Promise { - const foundSong = await this.songModel.findById(id).exec(); + public async deleteSong(id: string): Promise { + const foundSong = (await this.songModel + .findById(id) + .populate('uploader') + .exec()) as unknown as SongWithUser; if (!foundSong) { throw new HttpException('Song not found', HttpStatus.NOT_FOUND); } - await this.songModel.deleteOne({ _id: id }).exec(); - return UploadSongDto.fromSongDocument(foundSong); + // TODO: handle file deletion, Maybe move to a trash collection? + await this.songModel.deleteOne({ _id: id }).populate('uploader').exec(); + return UploadSongResponseDto.fromSongWithUserDocument(foundSong); } public async patchSong( id: string, body: UploadSongDto, user: UserDocument | null, - ): Promise { + ): Promise { if (!user) { throw new HttpException('User not found', HttpStatus.UNAUTHORIZED); } @@ -260,8 +268,10 @@ export class SongService { foundSong.originalAuthor = body.originalAuthor; foundSong.description = body.description; - const createdSong = await foundSong.save(); - return UploadSongDto.fromSongDocument(createdSong); + const createdSong = (await foundSong.save()).populate( + 'uploader', + ) as unknown as SongWithUser; + return UploadSongResponseDto.fromSongWithUserDocument(createdSong); } public async getSongByPage(query: PageQuery): Promise { @@ -272,7 +282,7 @@ export class SongService { sort: query.sort || 'createdAt', order: query.order || false, }; - const data = await this.songModel + const data = (await this.songModel .find({ visibility: 'public', }) @@ -282,7 +292,7 @@ export class SongService { .skip(options.skip) .limit(options.limit) .populate('uploader', 'username profileImage -_id') - .exec(); + .exec()) as unknown as SongWithUser[]; return data.map((song) => SongPreviewDto.fromSongDocument(song)); } @@ -370,7 +380,7 @@ export class SongService { const limit = parseInt(query.limit.toString()) || 10; const order = query.order ? query.order : false; const sort = query.sort ? query.sort : 'createdAt'; - const songData = await this.songModel + const songData = (await this.songModel .find({ uploader: user._id, }) @@ -379,7 +389,7 @@ export class SongService { }) .skip(limit * (page - 1)) .limit(limit) - .exec(); + .exec()) as unknown as SongWithUser[]; const total = await this.songModel .countDocuments({ uploader: user._id, From 9010a19cf1edfc8c5e44b3a3b4a3e73c4654cca9 Mon Sep 17 00:00:00 2001 From: tomast1337 Date: Tue, 21 May 2024 21:46:14 -0300 Subject: [PATCH 015/270] Refactor Song context types Co-authored-by: Bernardo Costa --- .../components/client/context/Song.context.tsx | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/web/src/modules/upload/components/client/context/Song.context.tsx b/web/src/modules/upload/components/client/context/Song.context.tsx index a05e3138..25daaed7 100644 --- a/web/src/modules/upload/components/client/context/Song.context.tsx +++ b/web/src/modules/upload/components/client/context/Song.context.tsx @@ -22,16 +22,13 @@ export const SongProvider = ({ children }: { children: React.ReactNode }) => { }; type ContextType = 'upload' | 'edit'; -export const useSongProvider = (type: T) => { +export const useSongProvider = ( + type: T, +): T extends 'upload' ? useUploadSongProviderType : useEditSongProviderType => { const uploadContext = useContext(UploadSongContext); const editContext = useContext(EditSongContext); - if (type === 'upload') { - return uploadContext as T extends 'upload' - ? useUploadSongProviderType - : useEditSongProviderType; - } else { - return editContext as T extends 'upload' - ? useUploadSongProviderType - : useEditSongProviderType; - } + const currentContext = type === 'upload' ? uploadContext : editContext; + return currentContext as T extends 'upload' + ? useUploadSongProviderType + : useEditSongProviderType; }; From e0349024c0cc71067651d3754053bbfed3aa8e2a Mon Sep 17 00:00:00 2001 From: tomast1337 Date: Tue, 21 May 2024 21:47:45 -0300 Subject: [PATCH 016/270] Refactor submitSongData to use await syntax Co-authored-by: Bernardo Costa --- .../client/context/UploadSong.context.tsx | 46 +++++++++++-------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/web/src/modules/upload/components/client/context/UploadSong.context.tsx b/web/src/modules/upload/components/client/context/UploadSong.context.tsx index 9dd5fda5..d70f2ef0 100644 --- a/web/src/modules/upload/components/client/context/UploadSong.context.tsx +++ b/web/src/modules/upload/components/client/context/UploadSong.context.tsx @@ -1,7 +1,7 @@ 'use client'; import { Song, fromArrayBuffer } from '@encode42/nbs.js'; import { zodResolver } from '@hookform/resolvers/zod'; -import { createContext, useEffect, useState } from 'react'; +import { createContext, useContext, useEffect, useState } from 'react'; import { FieldErrors, UseFormRegister, @@ -83,30 +83,27 @@ export const UploadSongProvider = ({ // Get authorization token from local storage const token = getTokenLocal(); - - // Send request - await axiosInstance - .post(`/song`, formData, { + try { + // Send request + const response = await axiosInstance.post(`/song`, formData, { headers: { authorization: `Bearer ${token}`, 'Content-Type': 'multipart/form-data', }, - }) - .then((response) => { - const data = response.data; - const id = data.publicId as string; - setUploadedSongId(id); - setIsUploadComplete(true); - }) - .catch((error) => { - console.error('Error submitting song', error); - if (error.response) { - setSendError(error.response.data.error.file); - } else { - setSendError('An unknown error occurred while submitting the song!'); - } - return; }); + + const data = response.data; + const id = data.publicId as string; + setUploadedSongId(id); + setIsUploadComplete(true); + } catch (error: any) { + console.error('Error submitting song', error); + if (error.response) { + setSendError(error.response.data.error.file); + } else { + setSendError('An unknown error occurred while submitting the song!'); + } + } }; const submitSong = async () => { @@ -116,6 +113,7 @@ export const UploadSongProvider = ({ setIsUploadComplete(true); } catch (e) { console.log(e); // TODO: handle error + //formMethods.setError('file', { message: 'An error occurred' }); } finally { setIsSubmitting(false); } @@ -149,6 +147,10 @@ export const UploadSongProvider = ({ 'custom2', 'custom3', ]); + + formMethods.setValue('allowDownload', true); + + // disable allowDownload } }, [song, formMethods]); @@ -173,3 +175,7 @@ export const UploadSongProvider = ({ ); }; + +export const useUploadSongProvider = (): useUploadSongProviderType => { + return useContext(UploadSongContext); +}; From b61984f010b0ae72f985539b5608347613306a00 Mon Sep 17 00:00:00 2001 From: tomast1337 Date: Tue, 21 May 2024 21:48:05 -0300 Subject: [PATCH 017/270] Update FormElements component to handle invalid state Co-authored-by: Bernardo Costa --- .../upload/components/client/FormElements.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/web/src/modules/upload/components/client/FormElements.tsx b/web/src/modules/upload/components/client/FormElements.tsx index 0ee0a7bd..b6575e4b 100644 --- a/web/src/modules/upload/components/client/FormElements.tsx +++ b/web/src/modules/upload/components/client/FormElements.tsx @@ -4,13 +4,17 @@ import { cn } from '@web/src/lib/tailwind.utils'; export const Input = forwardRef< HTMLInputElement, - React.InputHTMLAttributes + React.InputHTMLAttributes & { + invalid?: boolean; + } >((props, ref) => { return ( ); }); @@ -18,14 +22,18 @@ Input.displayName = 'Input'; export const Select = forwardRef< HTMLSelectElement, - React.SelectHTMLAttributes + React.SelectHTMLAttributes & { + invalid?: boolean; + } >((props, ref) => { return ( - + { { id='artist' disabled={true} className='block' - {...formMethods.register('artist')} + invalid={!!errors.artist} + {...register('artist')} />
- +

{"(Leave blank if it's an original song)"}

@@ -76,7 +81,7 @@ export const SongForm = ({ type }: { type: 'upload' | 'edit' }) => {
- @@ -109,7 +114,11 @@ export const SongForm = ({ type }: { type: 'upload' | 'edit' }) => {
- @@ -122,7 +131,7 @@ export const SongForm = ({ type }: { type: 'upload' | 'edit' }) => {
- @@ -146,7 +155,8 @@ export const SongForm = ({ type }: { type: 'upload' | 'edit' }) => {
@@ -112,6 +126,7 @@ export const SongThumbnailInput = ({ type }: { type: 'upload' | 'edit' }) => { ) => { e.preventDefault(); formMethods.setValue('thumbnailData.backgroundColor', color); From 34f5230291516ca110b94449e83a6886494e1eec Mon Sep 17 00:00:00 2001 From: Bernardo Costa Date: Mon, 3 Jun 2024 12:34:58 -0300 Subject: [PATCH 120/270] fix: thumbnail input sliders not working --- .../modules/song/components/client/SongThumbnailInput.tsx | 6 ++---- .../upload/components/client/context/UploadSong.context.tsx | 3 ++- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/web/src/modules/song/components/client/SongThumbnailInput.tsx b/web/src/modules/song/components/client/SongThumbnailInput.tsx index 9d7d164d..52cabdae 100644 --- a/web/src/modules/song/components/client/SongThumbnailInput.tsx +++ b/web/src/modules/song/components/client/SongThumbnailInput.tsx @@ -64,7 +64,6 @@ export const SongThumbnailInput = ({ className='w-full disabled:cursor-not-allowed' {...register('thumbnailData.zoomLevel', { valueAsNumber: true, - value: 3, max: 5, disabled: isLocked, })} @@ -85,11 +84,11 @@ export const SongThumbnailInput = ({ className='w-full disabled:cursor-not-allowed' {...register('thumbnailData.startTick', { valueAsNumber: true, - value: 0, max: maxTick, disabled: isLocked, })} disabled={isLocked} + min={0} max={maxTick} />
@@ -102,14 +101,13 @@ export const SongThumbnailInput = ({
diff --git a/web/src/modules/upload/components/client/context/UploadSong.context.tsx b/web/src/modules/upload/components/client/context/UploadSong.context.tsx index b6cd0b49..660a9310 100644 --- a/web/src/modules/upload/components/client/context/UploadSong.context.tsx +++ b/web/src/modules/upload/components/client/context/UploadSong.context.tsx @@ -142,9 +142,10 @@ export const UploadSongProvider = ({ formMethods.setValue('description', description); formMethods.setValue('originalAuthor', originalAuthor); }; + useEffect(() => { if (song) { - formMethods.setValue('thumbnailData.zoomLevel', 1); + formMethods.setValue('thumbnailData.zoomLevel', 3); formMethods.setValue('thumbnailData.startTick', 0); formMethods.setValue('thumbnailData.startLayer', 0); formMethods.setValue('thumbnailData.backgroundColor', '#ffffff'); From 8c26cc287e48a6003cde3ecabe87df3f92747855 Mon Sep 17 00:00:00 2001 From: Bernardo Costa Date: Mon, 3 Jun 2024 12:41:59 -0300 Subject: [PATCH 121/270] refactor: rename `EditSongPage` for consistency with upload --- web/src/app/(content)/song/[id]/edit/page.tsx | 2 +- .../components/client/{SongEditPages.tsx => EditSongPage.tsx} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename web/src/modules/song-edit/components/client/{SongEditPages.tsx => EditSongPage.tsx} (100%) diff --git a/web/src/app/(content)/song/[id]/edit/page.tsx b/web/src/app/(content)/song/[id]/edit/page.tsx index d2e1e3e7..9f41c093 100644 --- a/web/src/app/(content)/song/[id]/edit/page.tsx +++ b/web/src/app/(content)/song/[id]/edit/page.tsx @@ -1,4 +1,4 @@ -import { EditSongPage } from '@web/src/modules/song-edit/components/client/SongEditPages'; +import { EditSongPage } from '@web/src/modules/song-edit/components/client/EditSongPage'; function Page({ params }: { params: { id: string } }) { const { id } = params; diff --git a/web/src/modules/song-edit/components/client/SongEditPages.tsx b/web/src/modules/song-edit/components/client/EditSongPage.tsx similarity index 100% rename from web/src/modules/song-edit/components/client/SongEditPages.tsx rename to web/src/modules/song-edit/components/client/EditSongPage.tsx From 1fddee27a16a99fed630a9bad9f8921d50c064b7 Mon Sep 17 00:00:00 2001 From: Bernardo Costa Date: Mon, 3 Jun 2024 13:08:37 -0300 Subject: [PATCH 122/270] refactor: rename 'artist' to 'author' in form, and make it not required --- .../song-edit/components/client/SongEditForm.tsx | 4 ++++ .../components/client/context/EditSong.context.tsx | 2 +- web/src/modules/song/components/client/SongForm.tsx | 11 +++++------ .../modules/song/components/client/SongForm.zod.ts | 2 +- .../upload/components/client/SongUploadForm.tsx | 2 +- 5 files changed, 12 insertions(+), 9 deletions(-) diff --git a/web/src/modules/song-edit/components/client/SongEditForm.tsx b/web/src/modules/song-edit/components/client/SongEditForm.tsx index a27f1df1..148d2417 100644 --- a/web/src/modules/song-edit/components/client/SongEditForm.tsx +++ b/web/src/modules/song-edit/components/client/SongEditForm.tsx @@ -20,12 +20,16 @@ export const SongEditForm = ({ username, }: SongEditFormProps) => { const type = 'edit'; + const { loadSong, setSongId, song } = useSongProvider( type, ) as useEditSongProviderType; + useEffect(() => { loadSong(songId, username, songData); setSongId(songId); }, [loadSong, setSongId, songData, songId, username]); + // TODO: The username is injected into the form differently in SongUploadForm (defaultAuthorName) and SongEditForm (username). This should be consistent + return ; }; diff --git a/web/src/modules/song-edit/components/client/context/EditSong.context.tsx b/web/src/modules/song-edit/components/client/context/EditSong.context.tsx index 13b92947..561a11e0 100644 --- a/web/src/modules/song-edit/components/client/context/EditSong.context.tsx +++ b/web/src/modules/song-edit/components/client/context/EditSong.context.tsx @@ -116,7 +116,7 @@ export const EditSongProvider = ({ visibility: songData.visibility, title: songData.title, originalAuthor: songData.originalAuthor, - artist: username, + author: username, description: songData.description, thumbnailData: { zoomLevel: songData.thumbnailData.zoomLevel, diff --git a/web/src/modules/song/components/client/SongForm.tsx b/web/src/modules/song/components/client/SongForm.tsx index 54efa9ff..b3d18ef3 100644 --- a/web/src/modules/song/components/client/SongForm.tsx +++ b/web/src/modules/song/components/client/SongForm.tsx @@ -59,15 +59,14 @@ export const SongForm = ({ type, isLocked = false }: SongFormProps) => { {/* Author */}
- +
diff --git a/web/src/modules/song/components/client/SongForm.zod.ts b/web/src/modules/song/components/client/SongForm.zod.ts index 44efda85..745b3a9b 100644 --- a/web/src/modules/song/components/client/SongForm.zod.ts +++ b/web/src/modules/song/components/client/SongForm.zod.ts @@ -31,7 +31,7 @@ export const SongFormSchema = zod.object({ message: 'Original author must be less than 64 characters', }) .min(0), - artist: zod.string().min(0), + author: zod.string(), description: zod.string().max(1024, { message: 'Description must be less than 1024 characters', }), diff --git a/web/src/modules/upload/components/client/SongUploadForm.tsx b/web/src/modules/upload/components/client/SongUploadForm.tsx index 566e2ca8..f74ff9da 100644 --- a/web/src/modules/upload/components/client/SongUploadForm.tsx +++ b/web/src/modules/upload/components/client/SongUploadForm.tsx @@ -20,7 +20,7 @@ export const SongUploadForm = ({ }, [defaultAuthorName]); useEffect(() => { - formMethods.setValue('artist', defaultAuthorNameMemo); + formMethods.setValue('author', defaultAuthorNameMemo); }, [defaultAuthorName, defaultAuthorNameMemo, formMethods]); return ; From 0f02a901e9d0b7389a3563bf1f15ce1198c39803 Mon Sep 17 00:00:00 2001 From: Bernardo Costa Date: Mon, 3 Jun 2024 13:09:28 -0300 Subject: [PATCH 123/270] refactor: group form provider destructuring --- web/src/modules/song/components/client/SongForm.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/src/modules/song/components/client/SongForm.tsx b/web/src/modules/song/components/client/SongForm.tsx index b3d18ef3..d9d61628 100644 --- a/web/src/modules/song/components/client/SongForm.tsx +++ b/web/src/modules/song/components/client/SongForm.tsx @@ -18,9 +18,9 @@ export const SongForm = ({ type, isLocked = false }: SongFormProps) => { const useSongProviderData = useSongProvider( type, ) as useUploadSongProviderType & useEditSongProviderType; - const { sendError, errors, submitSong, isSubmitting } = useSongProviderData; - const formMethods = useSongProviderData.formMethods; - const { register } = useSongProviderData; + const { sendError, errors, submitSong, isSubmitting, formMethods, register } = + useSongProviderData; + return ( <>
Date: Mon, 3 Jun 2024 13:10:49 -0300 Subject: [PATCH 124/270] fix: add loading spinner while song is loading for edit (WIP) --- web/src/modules/song-edit/components/client/EditSongPage.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/src/modules/song-edit/components/client/EditSongPage.tsx b/web/src/modules/song-edit/components/client/EditSongPage.tsx index 91df44f1..5a1fde80 100644 --- a/web/src/modules/song-edit/components/client/EditSongPage.tsx +++ b/web/src/modules/song-edit/components/client/EditSongPage.tsx @@ -43,6 +43,8 @@ export async function EditSongPage({ id }: { id: string }) {

Editing {songData.title}

+ {/* TODO: spinner not showing */} + {songData === null &&
} {/* TODO: Show song file name */}
From ef45400ccf7394e4c119ef1ac0bbe3b006138b25 Mon Sep 17 00:00:00 2001 From: Bernardo Costa Date: Mon, 3 Jun 2024 13:13:16 -0300 Subject: [PATCH 125/270] fix: lock form after clicking submit --- web/src/modules/song-edit/components/client/SongEditForm.tsx | 4 ++-- web/src/modules/upload/components/client/SongUploadForm.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/web/src/modules/song-edit/components/client/SongEditForm.tsx b/web/src/modules/song-edit/components/client/SongEditForm.tsx index 148d2417..fa29bfa5 100644 --- a/web/src/modules/song-edit/components/client/SongEditForm.tsx +++ b/web/src/modules/song-edit/components/client/SongEditForm.tsx @@ -21,7 +21,7 @@ export const SongEditForm = ({ }: SongEditFormProps) => { const type = 'edit'; - const { loadSong, setSongId, song } = useSongProvider( + const { loadSong, setSongId, song, isSubmitting } = useSongProvider( type, ) as useEditSongProviderType; @@ -31,5 +31,5 @@ export const SongEditForm = ({ }, [loadSong, setSongId, songData, songId, username]); // TODO: The username is injected into the form differently in SongUploadForm (defaultAuthorName) and SongEditForm (username). This should be consistent - return ; + return ; }; diff --git a/web/src/modules/upload/components/client/SongUploadForm.tsx b/web/src/modules/upload/components/client/SongUploadForm.tsx index f74ff9da..74b48400 100644 --- a/web/src/modules/upload/components/client/SongUploadForm.tsx +++ b/web/src/modules/upload/components/client/SongUploadForm.tsx @@ -11,7 +11,7 @@ export const SongUploadForm = ({ defaultAuthorName: string; }) => { const type = 'upload'; - const { formMethods, song } = useSongProvider( + const { formMethods, song, isSubmitting } = useSongProvider( type, ) as useUploadSongProviderType; @@ -23,5 +23,5 @@ export const SongUploadForm = ({ formMethods.setValue('author', defaultAuthorNameMemo); }, [defaultAuthorName, defaultAuthorNameMemo, formMethods]); - return ; + return ; }; From f70a7ac4c8066fed23f39654c05986dd5c0cd723 Mon Sep 17 00:00:00 2001 From: Bernardo Costa Date: Mon, 3 Jun 2024 13:30:37 -0300 Subject: [PATCH 126/270] refactor: make label part of abstracted form components --- .../song/components/client/FormElements.tsx | 35 ++++++++++++-- .../song/components/client/SongForm.tsx | 47 +++++++++++-------- 2 files changed, 58 insertions(+), 24 deletions(-) diff --git a/web/src/modules/song/components/client/FormElements.tsx b/web/src/modules/song/components/client/FormElements.tsx index d2d98b22..d81d7697 100644 --- a/web/src/modules/song/components/client/FormElements.tsx +++ b/web/src/modules/song/components/client/FormElements.tsx @@ -3,17 +3,38 @@ import { forwardRef } from 'react'; import { cn } from '@web/src/lib/tailwind.utils'; import { ErrorBalloon } from '@web/src/modules/shared/components/client/ErrorBalloon'; +export const Area = ({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) => { + return ( + <> + +
+ {children} +
+ + ); +}; + export const Input = forwardRef< HTMLInputElement, React.InputHTMLAttributes & { + id: string; + label: string; errorMessage?: string; } >((props, ref) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { errorMessage, ...rest } = props; + const { id, label, errorMessage, ...rest } = props; return ( <> + & { + id: string; + label: string; errorMessage?: string; } >((props, ref) => { - const { errorMessage, ...rest } = props; + const { id, label, errorMessage, ...rest } = props; return ( <> +