From 358277e0b2920f1b70a347ed1c0d74f60e840db5 Mon Sep 17 00:00:00 2001 From: Youssouf EL Azizi Date: Thu, 14 Nov 2024 17:54:38 +0100 Subject: [PATCH] feat: new episode form page tweaking + timestamp input --- src/components/create-episode/form.tsx | 11 +- .../generate-episode-button.tsx | 77 +++++++-- .../generate-episode-markdown.ts | 8 +- src/components/create-episode/notes.tsx | 35 ++++- .../create-episode/timestamp-input.tsx | 148 ++++++++++++++++++ tailwind.config.cjs | 6 +- 6 files changed, 247 insertions(+), 38 deletions(-) create mode 100644 src/components/create-episode/timestamp-input.tsx diff --git a/src/components/create-episode/form.tsx b/src/components/create-episode/form.tsx index 2db6bf0d..d9aeadda 100644 --- a/src/components/create-episode/form.tsx +++ b/src/components/create-episode/form.tsx @@ -134,16 +134,13 @@ function NewEpisodeForm({ trigger, setError, clearErrors, + setValue, } = useForm({ resolver: zodResolver(episodeSchemaForm), defaultValues, mode: "onChange", }); - const onSubmit = (data: FormValues) => { - console.log(data); - }; - return (
@@ -154,6 +151,8 @@ function NewEpisodeForm({ setError={setError} watch={watch} clearErrors={clearErrors} + setValue={setValue} + totalDuration={getValues("duration")} /> -
-
+
{defaultValues.youtube && ( )} diff --git a/src/components/create-episode/generate-episode-button.tsx b/src/components/create-episode/generate-episode-button.tsx index 82335396..158f935f 100644 --- a/src/components/create-episode/generate-episode-button.tsx +++ b/src/components/create-episode/generate-episode-button.tsx @@ -33,28 +33,73 @@ export const GenerateEpisodeButton = ({ -
-
- {markdown} -
-
- +
+
+ +

+ Generated Episode Markdown +

+

+ Copy the episode content and create a pull request in the repository + to add this episode. The file should be placed in + episodes/episode-XXXX.md +

+ +
+
+ +
+
+              
+                {markdown}
+              
+            
+
diff --git a/src/components/create-episode/generate-episode-markdown.ts b/src/components/create-episode/generate-episode-markdown.ts index c9e9698f..78a68137 100644 --- a/src/components/create-episode/generate-episode-markdown.ts +++ b/src/components/create-episode/generate-episode-markdown.ts @@ -3,6 +3,8 @@ import type { episodeSchemaForm } from "./schema"; type FormValues = z.infer; +const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1); + export function generateEpisodeMarkdown(episode: FormValues): string { const formatDate = (date: Date) => date.toISOString().split("T")[0]; @@ -17,7 +19,7 @@ published: ${episode.published} featured: ${episode.featured} --- -${episode.description} +${capitalize(episode.description)} ## Guests @@ -25,7 +27,7 @@ ${episode.guests.map(guest => `- [${guest.title}](${guest.url})`).join("\n")} ## Notes -${episode.notes.map(note => `${note.timestamp} - ${note.content}`).join("\n\n")} +${episode.notes.map(note => `${note.timestamp} - ${capitalize(note.content)}`).join("\n\n")} ## Links @@ -33,7 +35,7 @@ ${episode.links.map(link => `- [${link.title}](${link.url})`).join("\n")} ## Prepared and Presented by -${episode.hosts.map(host => `- [${host.title}](${host.url})`).join("\n")} +${episode.hosts.map(host => `- [${capitalize(host.title)}](${host.url})`).join("\n")} `; return frontmatter; diff --git a/src/components/create-episode/notes.tsx b/src/components/create-episode/notes.tsx index b5ecb921..54f8e16f 100644 --- a/src/components/create-episode/notes.tsx +++ b/src/components/create-episode/notes.tsx @@ -5,12 +5,14 @@ import { type UseFormClearErrors, type UseFormRegister, type UseFormSetError, + type UseFormSetValue, type UseFormWatch, } from "react-hook-form"; import { type z } from "zod"; import { episodeSchemaForm } from "./schema"; import { AddIcon, RemoveIcon } from "./icons"; import React from "react"; +import TimestampInput from "./timestamp-input"; type FormValues = z.infer; @@ -41,6 +43,8 @@ export default function Notes({ setError, clearErrors, watch, + setValue, + totalDuration, }: { control: Control; register: UseFormRegister; @@ -48,6 +52,8 @@ export default function Notes({ setError: UseFormSetError; watch: UseFormWatch; clearErrors: UseFormClearErrors; + setValue: UseFormSetValue; + totalDuration: string; }) { const { fields: noteFields, @@ -62,10 +68,24 @@ export default function Notes({ const triggerOrderValidation = () => { if (!notes?.length) return; + const totalDurationInSeconds = timestampToSeconds(totalDuration); const timestamps = notes.map(note => note.timestamp); const secondsArray = timestamps.map(timestampToSeconds); + // Check for timestamps exceeding total duration + const exceedingIndex = secondsArray.findIndex( + seconds => seconds > totalDurationInSeconds + ); + if (exceedingIndex !== -1) { + setError(`notes`, { + type: "manual", + message: `Timestamp ${timestamps[exceedingIndex]} exceeds total duration of the video ${totalDuration}`, + }); + return; + } + + // Check timestamp order let isSorted = true; let firstUnsortedIndex = -1; @@ -97,14 +117,13 @@ export default function Notes({ {noteFields.map((field, index) => (
- triggerOrderValidation(), - })} - placeholder="00:00:00" - pattern="^(?:[0-9]{2}:){2}[0-9]{2}$" - className="w-[100px] rounded-md border-0 bg-gray-200 px-2 py-0.5 text-sm focus:border-blue-500 focus:ring-blue-500" - title="Please use format: HH:MM:SS" + { + setValue(`notes.${index}.timestamp`, value); + triggerOrderValidation(); + }} + defaultValue={field.timestamp} + id={`notes.${index}.timestamp`} />
diff --git a/src/components/create-episode/timestamp-input.tsx b/src/components/create-episode/timestamp-input.tsx new file mode 100644 index 00000000..581d5d9d --- /dev/null +++ b/src/components/create-episode/timestamp-input.tsx @@ -0,0 +1,148 @@ +import React, { useState } from "react"; + +interface TimestampInputProps { + onChange: (timestamp: string) => void; + id: string; + defaultValue: string; +} + +interface TimeState { + hours: string; + minutes: string; + seconds: string; +} + +const padWithZero = (value: string): string => { + return value.length === 1 ? `0${value}` : value; +}; + +const validateAndFormatNumber = (value: string, max: number): string => { + const cleanValue = value.replace(/\D/g, ""); + let numValue = parseInt(cleanValue); + if (isNaN(numValue)) return ""; + console.log({ numValue, max }); + if (numValue > max) numValue = max; + return numValue.toString(); +}; + +const parseTimestamp = (timestamp: string): TimeState => { + const [hours, minutes, seconds] = timestamp?.split(":") || ["", "", ""]; + return { hours, minutes, seconds }; +}; + +const TimestampInput: React.FC = ({ + onChange, + id, + defaultValue, +}) => { + const [time, setTime] = useState(parseTimestamp(defaultValue)); + + const handleInputChange = ( + e: React.ChangeEvent, + field: keyof TimeState, + max: number + ) => { + const formattedValue = validateAndFormatNumber(e.target.value, max); + console.log(formattedValue); + setTime(prev => ({ ...prev, [field]: formattedValue })); + + if (formattedValue.length === 2) { + selectNextField(field); + } + + const newTime = { + ...time, + [field]: formattedValue, + }; + onChange(`${newTime.hours}:${newTime.minutes}:${newTime.seconds}`); + }; + + const handleKeyDown = ( + e: React.KeyboardEvent, + field: keyof TimeState + ) => { + if (e.key === ":" || e.key === "ArrowRight") { + e.preventDefault(); + selectNextField(field); + } + }; + + const handleBlur = (field: keyof TimeState) => { + setTime(prev => ({ ...prev, [field]: padWithZero(prev[field]) })); + + const newTime = { + ...time, + [field]: padWithZero(time[field]), + }; + const timestamp = `${padWithZero(newTime.hours)}:${padWithZero(newTime.minutes)}:${padWithZero(newTime.seconds)}`; + onChange(timestamp); + }; + + const handleFocus = (e: React.FocusEvent) => { + e.target.select(); + }; + + const selectNextField = (field: keyof TimeState) => { + const nextFieldId = + field === "hours" + ? `${id}-minutes` + : field === "minutes" + ? `${id}-seconds` + : null; + const nextField = nextFieldId + ? (document.getElementById(nextFieldId) as HTMLInputElement) + : null; + nextField?.focus(); + }; + + return ( +
+ handleInputChange(e, "hours", 23)} + onKeyDown={e => handleKeyDown(e, "hours")} + className="w-[2ch] border-0 bg-transparent px-0 text-center focus:outline-none focus:ring-0" + maxLength={2} + placeholder="00" + id={`${id}-hours`} + aria-label="Hours" + onFocus={handleFocus} + onBlur={() => handleBlur("hours")} + /> + : + handleInputChange(e, "minutes", 59)} + onKeyDown={e => handleKeyDown(e, "minutes")} + className="w-[2ch] border-0 bg-transparent px-0 text-center focus:outline-none focus:ring-0" + maxLength={2} + placeholder="00" + id={`${id}-minutes`} + aria-label="Minutes" + onFocus={handleFocus} + onBlur={() => handleBlur("minutes")} + /> + : + handleInputChange(e, "seconds", 59)} + className="w-[2ch] border-0 bg-transparent px-0 text-center focus:outline-none focus:ring-0" + maxLength={2} + placeholder="00" + id={`${id}-seconds`} + aria-label="Seconds" + onFocus={handleFocus} + onBlur={() => handleBlur("seconds")} + /> +
+ ); +}; + +export default TimestampInput; diff --git a/tailwind.config.cjs b/tailwind.config.cjs index 8282742f..3d75e0d6 100644 --- a/tailwind.config.cjs +++ b/tailwind.config.cjs @@ -153,9 +153,5 @@ module.exports = { }, }, }, - plugins: [ - require("@tailwindcss/typography"), - require("tailwindcss-motion"), - require("@tailwindcss/aspect-ratio"), - ], + plugins: [require("@tailwindcss/typography"), require("tailwindcss-motion")], };