From 9992175738c4ef6a614120fe97b04ede6be9cb0d Mon Sep 17 00:00:00 2001 From: Manny Date: Thu, 8 May 2025 03:36:02 -0400 Subject: [PATCH] TS-42:audioplayback return, image and form submission and validation updates --- src/components/signature/index.tsx | 2 +- src/context/FormDataContext.tsx | 26 +++++- src/context/LicenseContext.tsx | 19 ++-- src/pages/anthem/index.tsx | 51 +++++++++-- src/pages/form/index.tsx | 139 +++++++++++++++++++++-------- src/pages/signature/index.tsx | 2 + src/pages/upload/index.tsx | 111 ++++++++++++++++++----- src/server/api/routers/fans.ts | 22 +++++ src/utils/get-artist-catalog.ts | 56 ++++++++++-- 9 files changed, 346 insertions(+), 82 deletions(-) diff --git a/src/components/signature/index.tsx b/src/components/signature/index.tsx index ca2ef8e..f2280a0 100644 --- a/src/components/signature/index.tsx +++ b/src/components/signature/index.tsx @@ -34,7 +34,7 @@ const FanSignature = () => { try { if (image) { await updateSignature({ - uuid: licenseID!, + uuid: licenseID, signature: image, }); } diff --git a/src/context/FormDataContext.tsx b/src/context/FormDataContext.tsx index 98cb0fe..5b451dd 100644 --- a/src/context/FormDataContext.tsx +++ b/src/context/FormDataContext.tsx @@ -1,10 +1,30 @@ import { createContext, useContext, useState, useMemo } from "react"; interface FormDataContextType { - formData: FormData | null; - setFormData: (data: FormData) => void; + formData: FormDataType; + setFormData: React.Dispatch>; } +export interface FormDataType { + uuid: string; + fullname: string; + email: string; + username: string; + dob: string; + location: string; + fileURL?: string; + files?: File[]; +} + +export const initialFormData: FormDataType = { + uuid: "", + fullname: "", + email: "", + username: "", + dob: "", + location: "", +}; + interface FormProviderProps { children: React.ReactNode; } @@ -20,7 +40,7 @@ export const useFormContext = () => { }; export const FormDataProvider: React.FC = ({ children }) => { - const [formData, setFormData] = useState(null); + const [formData, setFormData] = useState(initialFormData); const formDataMemoized = useMemo( () => ({ formData, setFormData }), [formData] diff --git a/src/context/LicenseContext.tsx b/src/context/LicenseContext.tsx index e2a4e5e..774f5d2 100644 --- a/src/context/LicenseContext.tsx +++ b/src/context/LicenseContext.tsx @@ -3,7 +3,7 @@ import { createContext, useContext, useState, useEffect, useMemo } from "react"; import { v4 as uuidv4 } from "uuid"; interface LicenseContextValue { - licenseID: string | null; + licenseID: string; setLicenseID: (licenseID: string) => void; } @@ -11,19 +11,20 @@ interface LicenseProviderProps { children: React.ReactNode; } -const LicenseContext = createContext({ - licenseID: null, - setLicenseID: () => { - /* no-op */ - }, -}); +const LicenseContext = createContext(null); -export const useLicense = () => useContext(LicenseContext); +export const useLicense = () => { + const context = useContext(LicenseContext); + if (!context) { + throw new Error("useLicense must be used within a LicenseProvider"); + } + return context; +}; export const LicenseProvider: React.FC = ({ children, }) => { - const [licenseID, setLicenseID] = useState(null); + const [licenseID, setLicenseID] = useState(""); // Generate a License ID when the component mounts useEffect(() => { diff --git a/src/pages/anthem/index.tsx b/src/pages/anthem/index.tsx index ff2a84b..384a139 100644 --- a/src/pages/anthem/index.tsx +++ b/src/pages/anthem/index.tsx @@ -5,6 +5,8 @@ import { useRouter } from "next/navigation"; import React, { useState, useEffect } from "react"; // ** Custom Components, Hooks, Utils, etc. +import MediaPlayer from "@/components/media-player"; +import { useFormContext } from "@/context/FormDataContext"; import { useLicense } from "@/context/LicenseContext"; import { useSpotify } from "@/context/SpotifyContext"; import type { Catalog } from "@/types/catalog"; @@ -34,11 +36,16 @@ const Anthem: React.FC = () => { const { artistCatalog } = useSpotify(); const { licenseID } = useLicense(); + const { mutateAsync: newFan } = api.fans.create.useMutation(); const { mutateAsync: updateAnthem } = api.fans.anthem.useMutation(); + const { formData } = useFormContext(); const [selectedAnthem, setSelectedAnthem] = useState(null); const [mounted, setMounted] = useState(false); + interface UploadResponse { + fileURL: string; + } // Add this to prevent hydration mismatch useEffect(() => { setMounted(true); @@ -47,7 +54,6 @@ const Anthem: React.FC = () => { if (!mounted) { return null; // or a loading skeleton } - const handleSubmit = async () => { if (!selectedAnthem) { alert("Please select an anthem first."); @@ -55,16 +61,43 @@ const Anthem: React.FC = () => { } try { - const data = await updateAnthem({ - uuid: licenseID!, - anthem: selectedAnthem, + const files = formData?.files; + const formDataUpload = new FormData(); + if (files) { + formDataUpload.append("file", files[0]); + } + + const uploadImageResponse = await fetch("/api/storage", { + method: "POST", + body: formDataUpload, }); - if (!data) { - throw new Error("Failed to save anthem"); + if (!uploadImageResponse.ok) { + throw new Error( + `Upload Image Error! status: ${uploadImageResponse.status}` + ); } - router.push("/signature"); + const { fileURL } = (await uploadImageResponse.json()) as UploadResponse; + + const response = await newFan({ + ...formData, + profilePicture: fileURL, + }); + + if (response) { + const data = await updateAnthem({ + uuid: licenseID, + anthem: selectedAnthem, + }); + if (data) { + router.push("/signature"); + } + + if (!data) { + throw new Error("Failed to save anthem"); + } + } } catch (error) { console.error("Error saving anthem:", error); alert("Failed to save anthem. Please try again."); @@ -92,6 +125,10 @@ const Anthem: React.FC = () => { /> )} + +
+ {selectedAnthem && } +
); diff --git a/src/pages/form/index.tsx b/src/pages/form/index.tsx index 632013c..b62b1f8 100644 --- a/src/pages/form/index.tsx +++ b/src/pages/form/index.tsx @@ -1,19 +1,22 @@ // ** React/Next.js Imports import { useRouter } from "next/navigation"; -import React, { type ChangeEvent } from "react"; +import React, { useEffect, useRef, type ChangeEvent } from "react"; // ** React95 Imports import { Input, Button } from "@react95/core"; // ** Third-Party Imports +import { TRPCClientError } from "@trpc/client"; import { useForm } from "react-hook-form"; // ** Custom Components, Hooks, Utils, etc. -import { useFormContext } from "@/context/FormDataContext"; +import { type FormDataType, useFormContext } from "@/context/FormDataContext"; import { useLicense } from "@/context/LicenseContext"; import { api } from "@/utils/trpc"; -interface FormInputs { +import { dataURLtoFile } from "../upload"; + +export interface FormInputs { fullname: string; email: string; username: string; @@ -43,58 +46,107 @@ interface UploadResponse { const Form = () => { const router = useRouter(); const { licenseID } = useLicense(); - const { formData } = useFormContext(); - const { mutateAsync: newFan } = api.fans.create.useMutation(); + const { formData, setFormData } = useFormContext(); + const { mutateAsync: validateUsername } = api.fans.validate.useMutation(); const { register, handleSubmit, - watch, + setError, setValue, + clearErrors, formState: { errors, isValid }, } = useForm({ - mode: "onBlur", + mode: "onTouched", + reValidateMode: "onChange", }); - // Handle username input transformation - const _username = watch("username"); + interface StoredFormData { + data: Partial; + savedAt: number; + } + + const initializedRef = useRef(false); + + useEffect(() => { + if (!formData?.fileURL) { + const stored = localStorage.getItem("formData"); + + if (stored) { + try { + const parsed = JSON.parse(stored) as StoredFormData; + const expired = Date.now() - parsed.savedAt > 20 * 60 * 1000; + + if (!expired && parsed.data) { + const parsedData: Partial = parsed.data; + + if (parsedData.fileURL) { + const file = dataURLtoFile(parsedData.fileURL, "recovered.png"); + + setFormData({ + ...parsedData, + files: [file], + } as FormDataType); + } + } else { + localStorage.removeItem("formData"); + } + } catch (err) { + console.error("Error restoring formData on /form:", err); + localStorage.removeItem("formData"); + } + } + } + + if (!formData || initializedRef.current) return; + + const fields: (keyof FormInputs)[] = [ + "fullname", + "email", + "username", + "dob", + "location", + ]; + + fields.forEach((field) => { + const value = formData[field]; + if (value) { + setValue(field, value, { shouldValidate: true }); + } + }); + + initializedRef.current = true; + //only rerun when formData changes, setFormData and setValue are stable + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [formData]); + + // Handle username input transformation const handleUsernameChange = (e: ChangeEvent) => { const value = e.target.value; const transformValue = value.startsWith("@") ? value : `@${value}`; setValue("username", transformValue); + clearErrors("username"); }; const formSubmission = async (data: FormInputs) => { - try { - const uploadImageResponse = await fetch("/api/storage", { - method: "POST", - body: formData, - }); - - if (!uploadImageResponse.ok) { - throw new Error( - `Upload Image Error! status: ${uploadImageResponse.status}` - ); - } + const updatedFormData = { + ...formData, + uuid: licenseID, + ...data, + }; - const { fileURL } = (await uploadImageResponse.json()) as UploadResponse; + setFormData(updatedFormData); - const response = await newFan({ - uuid: licenseID!, - ...data, - profilePicture: fileURL, - }); + localStorage.setItem( + "formData", + JSON.stringify({ + data: { ...updatedFormData }, + savedAt: Date.now(), // refresh timestamp + }) + ); - if (response) { - router.push("/anthem"); - } else { - // Handle error - throw new Error("Server error!"); - } - } catch (error) { - console.error("Error making requests:", error); - } + router.push("/anthem"); }; const onSubmit = async (data: FormInputs) => { @@ -104,6 +156,21 @@ const Form = () => { if (!formData) { throw new Error("Image Data is missing"); } + + try { + await validateUsername({ username: data.username }); + } catch (err) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (err instanceof TRPCClientError && err.data?.code === "CONFLICT") { + setError("username", { + type: "manual", + message: err.message || "Username already exists", + }); + return; + } + throw err; // unexpected error + } + formSubmission(data); }; @@ -198,7 +265,7 @@ const Form = () => { diff --git a/src/pages/signature/index.tsx b/src/pages/signature/index.tsx index f9cc6ed..69d4b0e 100644 --- a/src/pages/signature/index.tsx +++ b/src/pages/signature/index.tsx @@ -28,6 +28,8 @@ const Signature = () => { const { mutateAsync: welcomeEmail } = api.fans.email.useMutation(); const handleNext = async () => { + localStorage.removeItem("formData"); + router.push(`/license/${licenseID}`); if (fanData) { diff --git a/src/pages/upload/index.tsx b/src/pages/upload/index.tsx index 06d1709..6783a30 100644 --- a/src/pages/upload/index.tsx +++ b/src/pages/upload/index.tsx @@ -1,13 +1,13 @@ // ** React/Next.js Imports import Image from "next/image"; import { useRouter } from "next/navigation"; -import React, { useState, useRef } from "react"; +import React, { useState, useRef, useEffect } from "react"; // ** React95 Imports import { Button } from "@react95/core"; // ** Custom Components, Hooks, Utils, etc. -import { useFormContext } from "@/context/FormDataContext"; +import { type FormDataType, useFormContext } from "@/context/FormDataContext"; // ** Icon Imports import { HiUser } from "react-icons/hi2"; @@ -25,29 +25,100 @@ import { HiUser } from "react-icons/hi2"; * @param files - The selected files to be uploaded, or `null` if no files are selected. */ +export const dataURLtoFile = (dataurl: string, filename: string): File => { + const arr = dataurl.split(","); + const mimeMatch = /:(.*?);/.exec(arr[0]); + if (!mimeMatch) throw new Error("Invalid data URL"); + + const mime = mimeMatch[1]; + const bstr = atob(arr[1]); + const n = bstr.length; + const u8arr = new Uint8Array(n); + + for (let i = 0; i < n; i++) { + u8arr[i] = bstr.charCodeAt(i); + } + + return new File([u8arr], filename, { type: mime }); +}; + const Upload = () => { const [imageURL, setImageURL] = useState(null); const router = useRouter(); - const { setFormData } = useFormContext(); + const { formData, setFormData } = useFormContext(); + const fileInputRef = useRef(null); - const upload = async (files: FileList | null) => { - if (files && files.length > 0) { + interface StoredFormData { + data: Partial; + savedAt: number; + } + + // ✅ Recover base64 + reconstruct File after refresh + useEffect(() => { + const stored = localStorage.getItem("formData"); + + if (stored) { try { - const formData = new FormData(); - formData.append("file", files[0]); - setFormData(formData); - - const fileUrl = URL.createObjectURL(files[0]); - setImageURL(fileUrl); - } catch (error) { - console.error("Error:", error); + const parsed = JSON.parse(stored) as StoredFormData; + + const expired = Date.now() - parsed.savedAt > 20 * 60 * 1000; // 20 minutes + + if (!expired && parsed.data) { + const parsedData: Partial = parsed.data; + + if (parsedData.fileURL) { + const file = dataURLtoFile(parsedData.fileURL, "recovered.png"); + + setFormData({ + ...parsedData, + files: [file], // ✅ reconstruct File[] + } as FormDataType); + + setImageURL(parsedData.fileURL); + } + } else { + localStorage.removeItem("formData"); + } + } catch (err) { + console.error("Failed to restore formData from localStorage:", err); + localStorage.removeItem("formData"); } - } else { + } + }, []); + + const upload = async (files: FileList | null) => { + if (!files || files.length === 0) { alert("Please select a file to upload"); + return; } - }; - const fileInputRef = useRef(null); + const file = files[0]; + const reader = new FileReader(); + + reader.onloadend = () => { + const base64 = reader.result as string; + + setImageURL(base64); + const newFormData = { + ...formData, + fileURL: base64, + files: [file], + }; + + setFormData(newFormData); + + // ✅ Save to localStorage with timestamp + localStorage.setItem( + "formData", + JSON.stringify({ + data: { ...newFormData, files: undefined }, // exclude raw File + savedAt: Date.now(), + }) + ); + }; + + reader.readAsDataURL(file); + }; const handleDivClick = () => { fileInputRef.current?.click(); @@ -76,17 +147,17 @@ const Upload = () => { )} - {/* Hidden file input */} + { - upload(e.target.files); - }} + accept='image/*' + onChange={(e) => upload(e.target.files)} /> +