Skip to content

Commit

Permalink
chore(types): migrate video upload components and fix type issues
Browse files Browse the repository at this point in the history
- 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
joelhooks committed Jan 7, 2025
1 parent 6a17919 commit a59d72b
Show file tree
Hide file tree
Showing 13 changed files with 436 additions and 9 deletions.
4 changes: 1 addition & 3 deletions .vscode/settings.json
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
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ import {
export default async function AlreadySubscribedPage() {
const { session, ability } = await getServerAuthSession()

if (!session) {
return redirect('/')
}

const { user } = session

const { hasActiveSubscription } = await getSubscriptionStatus(user?.id)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useRouter } from 'next/navigation'
import { PostUploader } from '@/app/(content)/posts/_components/post-uploader'
import { NewResourceWithVideoForm } from '@/components/resources-crud/new-resource-with-video-form'
import { createPost } from '@/lib/posts-query'
import { getVideoResource } from '@/lib/video-resource-query'
import { FilePlus2 } from 'lucide-react'
Expand All @@ -15,7 +16,6 @@ import {
DialogHeader,
DialogTrigger,
} from '@coursebuilder/ui'
import { NewResourceWithVideoForm } from '@coursebuilder/ui/resources-crud/new-resource-with-video-form'

export function CreatePost() {
const router = useRouter()
Expand Down
4 changes: 4 additions & 0 deletions apps/ai-hero/src/app/(user)/profile/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ export default async function ProfilePage() {
redirect('/')
}

if (!session) {
return redirect('/')
}

if (!session.user) {
notFound()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export async function sendFeedbackFromUser({
try {
const { session } = await getServerAuthSession()
const user = (await db.query.users.findFirst({
where: eq(users.email, session.user?.email?.toLowerCase() || 'NO-EMAIL'),
where: eq(users.email, session?.user?.email?.toLowerCase() || 'NO-EMAIL'),
})) || { email: emailAddress, id: null, name: null }

if (!user.email) {
Expand Down
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}
/>
)
}
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>
)
}
Loading

0 comments on commit a59d72b

Please sign in to comment.