-
Notifications
You must be signed in to change notification settings - Fork 28
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
chore(types): migrate video upload components and fix type issues
- Migrated video upload form components from egghead - Fixed TypeScript issues in profile and subscription pages - Improved types in discord and progress utilities - Enhanced type safety in uploadthing core - Updated feedback widget action types
- Loading branch information
Showing
13 changed files
with
436 additions
and
9 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,7 +1,5 @@ | ||
{ | ||
"typescript.tsdk": "node_modules/typescript/lib", | ||
"cSpell.words": [ | ||
"coursebuilder" | ||
], | ||
"cSpell.words": ["coursebuilder"], | ||
"vitest.disableWorkspaceWarning": true | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
32 changes: 32 additions & 0 deletions
32
apps/ai-hero/src/components/resources-crud/new-lesson-video-form.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
import * as React from 'react' | ||
import { useRouter } from 'next/navigation' | ||
import { VideoUploader } from '@/components/resources-crud/video-uploader' | ||
import { pollVideoResource } from '@/utils/poll-video-resource' | ||
|
||
export function NewLessonVideoForm({ | ||
parentResourceId, | ||
onVideoResourceCreated, | ||
onVideoUploadCompleted, | ||
}: { | ||
parentResourceId: string | ||
onVideoResourceCreated: (videoResourceId: string) => void | ||
onVideoUploadCompleted: (videoResourceId: string) => void | ||
}) { | ||
const router = useRouter() | ||
|
||
async function handleSetVideoResourceId(videoResourceId: string) { | ||
try { | ||
onVideoUploadCompleted(videoResourceId) | ||
await pollVideoResource(videoResourceId).next() | ||
onVideoResourceCreated(videoResourceId) | ||
router.refresh() | ||
} catch (error) {} | ||
} | ||
|
||
return ( | ||
<VideoUploader | ||
setVideoResourceId={handleSetVideoResourceId} | ||
parentResourceId={parentResourceId} | ||
/> | ||
) | ||
} |
253 changes: 253 additions & 0 deletions
253
apps/ai-hero/src/components/resources-crud/new-resource-with-video-form.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,253 @@ | ||
'use client' | ||
|
||
import * as React from 'react' | ||
import { NewPost, PostType, PostTypeSchema } from '@/lib/posts' | ||
import { zodResolver } from '@hookform/resolvers/zod' | ||
import { useForm } from 'react-hook-form' | ||
import { z } from 'zod' | ||
|
||
import { | ||
type ContentResource, | ||
type VideoResource, | ||
} from '@coursebuilder/core/schemas' | ||
import { | ||
Button, | ||
Form, | ||
FormControl, | ||
FormDescription, | ||
FormField, | ||
FormItem, | ||
FormLabel, | ||
FormMessage, | ||
Input, | ||
Select, | ||
SelectContent, | ||
SelectItem, | ||
SelectTrigger, | ||
SelectValue, | ||
} from '@coursebuilder/ui' | ||
import { cn } from '@coursebuilder/ui/utils/cn' | ||
|
||
import { VideoUploadFormItem } from './video-upload-form-item' | ||
|
||
const NewResourceWithVideoSchema = z.object({ | ||
title: z.string().min(2).max(90), | ||
videoResourceId: z.string().min(4, 'Please upload a video'), | ||
}) | ||
|
||
type NewResourceWithVideo = z.infer<typeof NewResourceWithVideoSchema> | ||
|
||
const FormValuesSchema = NewResourceWithVideoSchema.extend({ | ||
postType: PostTypeSchema, | ||
videoResourceId: z.string().optional(), | ||
}) | ||
|
||
type FormValues = z.infer<typeof FormValuesSchema> | ||
|
||
export function NewResourceWithVideoForm({ | ||
getVideoResource, | ||
createResource, | ||
onResourceCreated, | ||
availableResourceTypes, | ||
className, | ||
children, | ||
uploadEnabled = true, | ||
}: { | ||
getVideoResource: (idOrSlug?: string) => Promise<VideoResource | null> | ||
createResource: (values: NewPost) => Promise<ContentResource> | ||
onResourceCreated: (resource: ContentResource, title: string) => Promise<void> | ||
availableResourceTypes?: PostType[] | undefined | ||
className?: string | ||
children: ( | ||
handleSetVideoResourceId: (value: string) => void, | ||
) => React.ReactNode | ||
uploadEnabled?: boolean | ||
}) { | ||
const [videoResourceId, setVideoResourceId] = React.useState< | ||
string | undefined | ||
>() | ||
const [videoResourceValid, setVideoResourceValid] = | ||
React.useState<boolean>(false) | ||
const [isValidatingVideoResource, setIsValidatingVideoResource] = | ||
React.useState<boolean>(false) | ||
|
||
const form = useForm<FormValues>({ | ||
resolver: zodResolver(FormValuesSchema), | ||
defaultValues: { | ||
title: '', | ||
videoResourceId: undefined, | ||
postType: availableResourceTypes?.[0] || 'lesson', | ||
}, | ||
}) | ||
|
||
async function* pollVideoResource( | ||
videoResourceId: string, | ||
maxAttempts = 30, | ||
initialDelay = 250, | ||
delayIncrement = 250, | ||
) { | ||
let delay = initialDelay | ||
|
||
for (let attempt = 1; attempt <= maxAttempts; attempt++) { | ||
const videoResource = await getVideoResource(videoResourceId) | ||
if (videoResource) { | ||
yield videoResource | ||
return | ||
} | ||
|
||
await new Promise((resolve) => setTimeout(resolve, delay)) | ||
delay += delayIncrement | ||
} | ||
|
||
throw new Error('Video resource not found after maximum attempts') | ||
} | ||
|
||
const [isSubmitting, setIsSubmitting] = React.useState(false) | ||
|
||
const onSubmit = async (values: FormValues) => { | ||
try { | ||
setIsSubmitting(true) | ||
if (values.videoResourceId) { | ||
await pollVideoResource(values.videoResourceId).next() | ||
} | ||
const resource = await createResource(values as any) | ||
if (!resource) { | ||
// Handle edge case, e.g., toast an error message | ||
console.log('no resource in onSubmit') | ||
return | ||
} | ||
|
||
onResourceCreated(resource, form.watch('title')) | ||
} catch (error) { | ||
console.error('Error polling video resource:', error) | ||
// handle error, e.g. toast an error message | ||
} finally { | ||
form.reset() | ||
setVideoResourceId(videoResourceId) | ||
form.setValue('videoResourceId', '') | ||
setIsSubmitting(false) | ||
} | ||
} | ||
|
||
async function handleSetVideoResourceId(videoResourceId: string) { | ||
try { | ||
setVideoResourceId(videoResourceId) | ||
setIsValidatingVideoResource(true) | ||
form.setValue('videoResourceId', videoResourceId) | ||
await pollVideoResource(videoResourceId).next() | ||
|
||
setVideoResourceValid(true) | ||
setIsValidatingVideoResource(false) | ||
} catch (error) { | ||
setVideoResourceValid(false) | ||
form.setError('videoResourceId', { message: 'Video resource not found' }) | ||
setVideoResourceId('') | ||
form.setValue('videoResourceId', '') | ||
setIsValidatingVideoResource(false) | ||
} | ||
} | ||
|
||
const selectedPostType = form.watch('postType') | ||
|
||
return ( | ||
<Form {...form}> | ||
<form | ||
onSubmit={form.handleSubmit(onSubmit)} | ||
className={cn('flex flex-col gap-5', className)} | ||
> | ||
<FormField | ||
control={form.control} | ||
name="title" | ||
render={({ field }) => ( | ||
<FormItem> | ||
<FormLabel>Title</FormLabel> | ||
<FormDescription> | ||
A title should summarize the resource and explain what it is | ||
about clearly. | ||
</FormDescription> | ||
<FormControl> | ||
<Input {...field} /> | ||
</FormControl> | ||
<FormMessage /> | ||
</FormItem> | ||
)} | ||
/> | ||
{availableResourceTypes && ( | ||
<FormField | ||
control={form.control} | ||
name="postType" | ||
render={({ field }) => { | ||
const descriptions = { | ||
lesson: | ||
'A traditional egghead lesson video. (upload on next screen)', | ||
article: 'A standard article', | ||
podcast: | ||
'A podcast episode that will be distributed across podcast networks via the egghead podcast', | ||
course: | ||
'A collection of lessons that will be distributed as a course', | ||
} | ||
|
||
return ( | ||
<FormItem> | ||
<FormLabel>Type</FormLabel> | ||
<FormDescription> | ||
Select the type of resource you are creating. | ||
</FormDescription> | ||
<FormControl> | ||
<Select | ||
onValueChange={field.onChange} | ||
defaultValue={field.value} | ||
> | ||
<SelectTrigger className="capitalize"> | ||
<SelectValue placeholder="Select Type..." /> | ||
</SelectTrigger> | ||
<SelectContent> | ||
{availableResourceTypes.map((type) => ( | ||
<SelectItem | ||
className="capitalize" | ||
value={type} | ||
key={type} | ||
> | ||
{type} | ||
</SelectItem> | ||
))} | ||
</SelectContent> | ||
</Select> | ||
</FormControl> | ||
{field.value && ( | ||
<div className="bg-muted text-muted-foreground mx-auto mt-2 max-w-[300px] whitespace-normal break-words rounded-md p-3 py-2 text-sm"> | ||
{descriptions[field.value as keyof typeof descriptions]} | ||
</div> | ||
)} | ||
<FormMessage /> | ||
</FormItem> | ||
) | ||
}} | ||
/> | ||
)} | ||
{uploadEnabled && ( | ||
<VideoUploadFormItem | ||
selectedPostType={selectedPostType} | ||
form={form} | ||
videoResourceId={videoResourceId} | ||
setVideoResourceId={setVideoResourceId} | ||
handleSetVideoResourceId={handleSetVideoResourceId} | ||
isValidatingVideoResource={isValidatingVideoResource} | ||
videoResourceValid={videoResourceValid} | ||
> | ||
{children} | ||
</VideoUploadFormItem> | ||
)} | ||
<Button | ||
type="submit" | ||
variant="default" | ||
disabled={ | ||
(videoResourceId ? !videoResourceValid : false) || isSubmitting | ||
} | ||
> | ||
{isSubmitting ? 'Creating...' : 'Create Draft Resource'} | ||
</Button> | ||
</form> | ||
</Form> | ||
) | ||
} |
Oops, something went wrong.