Skip to content

Commit a59d72b

Browse files
committed
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
1 parent 6a17919 commit a59d72b

File tree

13 files changed

+436
-9
lines changed

13 files changed

+436
-9
lines changed

.vscode/settings.json

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
{
22
"typescript.tsdk": "node_modules/typescript/lib",
3-
"cSpell.words": [
4-
"coursebuilder"
5-
],
3+
"cSpell.words": ["coursebuilder"],
64
"vitest.disableWorkspaceWarning": true
75
}

apps/ai-hero/src/app/(commerce)/subscribe/already-subscribed/page.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ import {
1616
export default async function AlreadySubscribedPage() {
1717
const { session, ability } = await getServerAuthSession()
1818

19+
if (!session) {
20+
return redirect('/')
21+
}
22+
1923
const { user } = session
2024

2125
const { hasActiveSubscription } = await getSubscriptionStatus(user?.id)

apps/ai-hero/src/app/(content)/posts/_components/create-post.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { useRouter } from 'next/navigation'
44
import { PostUploader } from '@/app/(content)/posts/_components/post-uploader'
5+
import { NewResourceWithVideoForm } from '@/components/resources-crud/new-resource-with-video-form'
56
import { createPost } from '@/lib/posts-query'
67
import { getVideoResource } from '@/lib/video-resource-query'
78
import { FilePlus2 } from 'lucide-react'
@@ -15,7 +16,6 @@ import {
1516
DialogHeader,
1617
DialogTrigger,
1718
} from '@coursebuilder/ui'
18-
import { NewResourceWithVideoForm } from '@coursebuilder/ui/resources-crud/new-resource-with-video-form'
1919

2020
export function CreatePost() {
2121
const router = useRouter()

apps/ai-hero/src/app/(user)/profile/page.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ export default async function ProfilePage() {
1414
redirect('/')
1515
}
1616

17+
if (!session) {
18+
return redirect('/')
19+
}
20+
1721
if (!session.user) {
1822
notFound()
1923
}

apps/ai-hero/src/components/feedback-widget/feedback-actions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export async function sendFeedbackFromUser({
2828
try {
2929
const { session } = await getServerAuthSession()
3030
const user = (await db.query.users.findFirst({
31-
where: eq(users.email, session.user?.email?.toLowerCase() || 'NO-EMAIL'),
31+
where: eq(users.email, session?.user?.email?.toLowerCase() || 'NO-EMAIL'),
3232
})) || { email: emailAddress, id: null, name: null }
3333

3434
if (!user.email) {
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import * as React from 'react'
2+
import { useRouter } from 'next/navigation'
3+
import { VideoUploader } from '@/components/resources-crud/video-uploader'
4+
import { pollVideoResource } from '@/utils/poll-video-resource'
5+
6+
export function NewLessonVideoForm({
7+
parentResourceId,
8+
onVideoResourceCreated,
9+
onVideoUploadCompleted,
10+
}: {
11+
parentResourceId: string
12+
onVideoResourceCreated: (videoResourceId: string) => void
13+
onVideoUploadCompleted: (videoResourceId: string) => void
14+
}) {
15+
const router = useRouter()
16+
17+
async function handleSetVideoResourceId(videoResourceId: string) {
18+
try {
19+
onVideoUploadCompleted(videoResourceId)
20+
await pollVideoResource(videoResourceId).next()
21+
onVideoResourceCreated(videoResourceId)
22+
router.refresh()
23+
} catch (error) {}
24+
}
25+
26+
return (
27+
<VideoUploader
28+
setVideoResourceId={handleSetVideoResourceId}
29+
parentResourceId={parentResourceId}
30+
/>
31+
)
32+
}
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
'use client'
2+
3+
import * as React from 'react'
4+
import { NewPost, PostType, PostTypeSchema } from '@/lib/posts'
5+
import { zodResolver } from '@hookform/resolvers/zod'
6+
import { useForm } from 'react-hook-form'
7+
import { z } from 'zod'
8+
9+
import {
10+
type ContentResource,
11+
type VideoResource,
12+
} from '@coursebuilder/core/schemas'
13+
import {
14+
Button,
15+
Form,
16+
FormControl,
17+
FormDescription,
18+
FormField,
19+
FormItem,
20+
FormLabel,
21+
FormMessage,
22+
Input,
23+
Select,
24+
SelectContent,
25+
SelectItem,
26+
SelectTrigger,
27+
SelectValue,
28+
} from '@coursebuilder/ui'
29+
import { cn } from '@coursebuilder/ui/utils/cn'
30+
31+
import { VideoUploadFormItem } from './video-upload-form-item'
32+
33+
const NewResourceWithVideoSchema = z.object({
34+
title: z.string().min(2).max(90),
35+
videoResourceId: z.string().min(4, 'Please upload a video'),
36+
})
37+
38+
type NewResourceWithVideo = z.infer<typeof NewResourceWithVideoSchema>
39+
40+
const FormValuesSchema = NewResourceWithVideoSchema.extend({
41+
postType: PostTypeSchema,
42+
videoResourceId: z.string().optional(),
43+
})
44+
45+
type FormValues = z.infer<typeof FormValuesSchema>
46+
47+
export function NewResourceWithVideoForm({
48+
getVideoResource,
49+
createResource,
50+
onResourceCreated,
51+
availableResourceTypes,
52+
className,
53+
children,
54+
uploadEnabled = true,
55+
}: {
56+
getVideoResource: (idOrSlug?: string) => Promise<VideoResource | null>
57+
createResource: (values: NewPost) => Promise<ContentResource>
58+
onResourceCreated: (resource: ContentResource, title: string) => Promise<void>
59+
availableResourceTypes?: PostType[] | undefined
60+
className?: string
61+
children: (
62+
handleSetVideoResourceId: (value: string) => void,
63+
) => React.ReactNode
64+
uploadEnabled?: boolean
65+
}) {
66+
const [videoResourceId, setVideoResourceId] = React.useState<
67+
string | undefined
68+
>()
69+
const [videoResourceValid, setVideoResourceValid] =
70+
React.useState<boolean>(false)
71+
const [isValidatingVideoResource, setIsValidatingVideoResource] =
72+
React.useState<boolean>(false)
73+
74+
const form = useForm<FormValues>({
75+
resolver: zodResolver(FormValuesSchema),
76+
defaultValues: {
77+
title: '',
78+
videoResourceId: undefined,
79+
postType: availableResourceTypes?.[0] || 'lesson',
80+
},
81+
})
82+
83+
async function* pollVideoResource(
84+
videoResourceId: string,
85+
maxAttempts = 30,
86+
initialDelay = 250,
87+
delayIncrement = 250,
88+
) {
89+
let delay = initialDelay
90+
91+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
92+
const videoResource = await getVideoResource(videoResourceId)
93+
if (videoResource) {
94+
yield videoResource
95+
return
96+
}
97+
98+
await new Promise((resolve) => setTimeout(resolve, delay))
99+
delay += delayIncrement
100+
}
101+
102+
throw new Error('Video resource not found after maximum attempts')
103+
}
104+
105+
const [isSubmitting, setIsSubmitting] = React.useState(false)
106+
107+
const onSubmit = async (values: FormValues) => {
108+
try {
109+
setIsSubmitting(true)
110+
if (values.videoResourceId) {
111+
await pollVideoResource(values.videoResourceId).next()
112+
}
113+
const resource = await createResource(values as any)
114+
if (!resource) {
115+
// Handle edge case, e.g., toast an error message
116+
console.log('no resource in onSubmit')
117+
return
118+
}
119+
120+
onResourceCreated(resource, form.watch('title'))
121+
} catch (error) {
122+
console.error('Error polling video resource:', error)
123+
// handle error, e.g. toast an error message
124+
} finally {
125+
form.reset()
126+
setVideoResourceId(videoResourceId)
127+
form.setValue('videoResourceId', '')
128+
setIsSubmitting(false)
129+
}
130+
}
131+
132+
async function handleSetVideoResourceId(videoResourceId: string) {
133+
try {
134+
setVideoResourceId(videoResourceId)
135+
setIsValidatingVideoResource(true)
136+
form.setValue('videoResourceId', videoResourceId)
137+
await pollVideoResource(videoResourceId).next()
138+
139+
setVideoResourceValid(true)
140+
setIsValidatingVideoResource(false)
141+
} catch (error) {
142+
setVideoResourceValid(false)
143+
form.setError('videoResourceId', { message: 'Video resource not found' })
144+
setVideoResourceId('')
145+
form.setValue('videoResourceId', '')
146+
setIsValidatingVideoResource(false)
147+
}
148+
}
149+
150+
const selectedPostType = form.watch('postType')
151+
152+
return (
153+
<Form {...form}>
154+
<form
155+
onSubmit={form.handleSubmit(onSubmit)}
156+
className={cn('flex flex-col gap-5', className)}
157+
>
158+
<FormField
159+
control={form.control}
160+
name="title"
161+
render={({ field }) => (
162+
<FormItem>
163+
<FormLabel>Title</FormLabel>
164+
<FormDescription>
165+
A title should summarize the resource and explain what it is
166+
about clearly.
167+
</FormDescription>
168+
<FormControl>
169+
<Input {...field} />
170+
</FormControl>
171+
<FormMessage />
172+
</FormItem>
173+
)}
174+
/>
175+
{availableResourceTypes && (
176+
<FormField
177+
control={form.control}
178+
name="postType"
179+
render={({ field }) => {
180+
const descriptions = {
181+
lesson:
182+
'A traditional egghead lesson video. (upload on next screen)',
183+
article: 'A standard article',
184+
podcast:
185+
'A podcast episode that will be distributed across podcast networks via the egghead podcast',
186+
course:
187+
'A collection of lessons that will be distributed as a course',
188+
}
189+
190+
return (
191+
<FormItem>
192+
<FormLabel>Type</FormLabel>
193+
<FormDescription>
194+
Select the type of resource you are creating.
195+
</FormDescription>
196+
<FormControl>
197+
<Select
198+
onValueChange={field.onChange}
199+
defaultValue={field.value}
200+
>
201+
<SelectTrigger className="capitalize">
202+
<SelectValue placeholder="Select Type..." />
203+
</SelectTrigger>
204+
<SelectContent>
205+
{availableResourceTypes.map((type) => (
206+
<SelectItem
207+
className="capitalize"
208+
value={type}
209+
key={type}
210+
>
211+
{type}
212+
</SelectItem>
213+
))}
214+
</SelectContent>
215+
</Select>
216+
</FormControl>
217+
{field.value && (
218+
<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">
219+
{descriptions[field.value as keyof typeof descriptions]}
220+
</div>
221+
)}
222+
<FormMessage />
223+
</FormItem>
224+
)
225+
}}
226+
/>
227+
)}
228+
{uploadEnabled && (
229+
<VideoUploadFormItem
230+
selectedPostType={selectedPostType}
231+
form={form}
232+
videoResourceId={videoResourceId}
233+
setVideoResourceId={setVideoResourceId}
234+
handleSetVideoResourceId={handleSetVideoResourceId}
235+
isValidatingVideoResource={isValidatingVideoResource}
236+
videoResourceValid={videoResourceValid}
237+
>
238+
{children}
239+
</VideoUploadFormItem>
240+
)}
241+
<Button
242+
type="submit"
243+
variant="default"
244+
disabled={
245+
(videoResourceId ? !videoResourceValid : false) || isSubmitting
246+
}
247+
>
248+
{isSubmitting ? 'Creating...' : 'Create Draft Resource'}
249+
</Button>
250+
</form>
251+
</Form>
252+
)
253+
}

0 commit comments

Comments
 (0)