From a59d72b2a776a02e050e6d7dca108dc81b90bbba Mon Sep 17 00:00:00 2001 From: joel Date: Mon, 6 Jan 2025 18:53:30 -0800 Subject: [PATCH] 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 --- .vscode/settings.json | 4 +- .../subscribe/already-subscribed/page.tsx | 4 + .../posts/_components/create-post.tsx | 2 +- apps/ai-hero/src/app/(user)/profile/page.tsx | 4 + .../feedback-widget/feedback-actions.ts | 2 +- .../resources-crud/new-lesson-video-form.tsx | 32 +++ .../new-resource-with-video-form.tsx | 253 ++++++++++++++++++ .../resources-crud/video-upload-form-item.tsx | 87 ++++++ .../resources-crud/video-uploader.tsx | 39 +++ apps/ai-hero/src/lib/discord-query.ts | 2 +- apps/ai-hero/src/lib/posts.ts | 12 +- apps/ai-hero/src/lib/progress.ts | 2 +- apps/ai-hero/src/uploadthing/core.ts | 2 +- 13 files changed, 436 insertions(+), 9 deletions(-) create mode 100644 apps/ai-hero/src/components/resources-crud/new-lesson-video-form.tsx create mode 100644 apps/ai-hero/src/components/resources-crud/new-resource-with-video-form.tsx create mode 100644 apps/ai-hero/src/components/resources-crud/video-upload-form-item.tsx create mode 100644 apps/ai-hero/src/components/resources-crud/video-uploader.tsx diff --git a/.vscode/settings.json b/.vscode/settings.json index 1ee2804e7..d1aaeb9ae 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,5 @@ { "typescript.tsdk": "node_modules/typescript/lib", - "cSpell.words": [ - "coursebuilder" - ], + "cSpell.words": ["coursebuilder"], "vitest.disableWorkspaceWarning": true } diff --git a/apps/ai-hero/src/app/(commerce)/subscribe/already-subscribed/page.tsx b/apps/ai-hero/src/app/(commerce)/subscribe/already-subscribed/page.tsx index 3cfe588f9..1cae47ba8 100644 --- a/apps/ai-hero/src/app/(commerce)/subscribe/already-subscribed/page.tsx +++ b/apps/ai-hero/src/app/(commerce)/subscribe/already-subscribed/page.tsx @@ -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) diff --git a/apps/ai-hero/src/app/(content)/posts/_components/create-post.tsx b/apps/ai-hero/src/app/(content)/posts/_components/create-post.tsx index f292be83b..af0b0f17a 100644 --- a/apps/ai-hero/src/app/(content)/posts/_components/create-post.tsx +++ b/apps/ai-hero/src/app/(content)/posts/_components/create-post.tsx @@ -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' @@ -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() diff --git a/apps/ai-hero/src/app/(user)/profile/page.tsx b/apps/ai-hero/src/app/(user)/profile/page.tsx index ee9023f8d..1aabc0712 100644 --- a/apps/ai-hero/src/app/(user)/profile/page.tsx +++ b/apps/ai-hero/src/app/(user)/profile/page.tsx @@ -14,6 +14,10 @@ export default async function ProfilePage() { redirect('/') } + if (!session) { + return redirect('/') + } + if (!session.user) { notFound() } diff --git a/apps/ai-hero/src/components/feedback-widget/feedback-actions.ts b/apps/ai-hero/src/components/feedback-widget/feedback-actions.ts index c39193f2d..b73daa776 100644 --- a/apps/ai-hero/src/components/feedback-widget/feedback-actions.ts +++ b/apps/ai-hero/src/components/feedback-widget/feedback-actions.ts @@ -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) { diff --git a/apps/ai-hero/src/components/resources-crud/new-lesson-video-form.tsx b/apps/ai-hero/src/components/resources-crud/new-lesson-video-form.tsx new file mode 100644 index 000000000..01165474a --- /dev/null +++ b/apps/ai-hero/src/components/resources-crud/new-lesson-video-form.tsx @@ -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 ( + + ) +} diff --git a/apps/ai-hero/src/components/resources-crud/new-resource-with-video-form.tsx b/apps/ai-hero/src/components/resources-crud/new-resource-with-video-form.tsx new file mode 100644 index 000000000..7c3904023 --- /dev/null +++ b/apps/ai-hero/src/components/resources-crud/new-resource-with-video-form.tsx @@ -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 + +const FormValuesSchema = NewResourceWithVideoSchema.extend({ + postType: PostTypeSchema, + videoResourceId: z.string().optional(), +}) + +type FormValues = z.infer + +export function NewResourceWithVideoForm({ + getVideoResource, + createResource, + onResourceCreated, + availableResourceTypes, + className, + children, + uploadEnabled = true, +}: { + getVideoResource: (idOrSlug?: string) => Promise + createResource: (values: NewPost) => Promise + onResourceCreated: (resource: ContentResource, title: string) => Promise + 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(false) + const [isValidatingVideoResource, setIsValidatingVideoResource] = + React.useState(false) + + const form = useForm({ + 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 ( +
+ + ( + + Title + + A title should summarize the resource and explain what it is + about clearly. + + + + + + + )} + /> + {availableResourceTypes && ( + { + 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 ( + + Type + + Select the type of resource you are creating. + + + + + {field.value && ( +
+ {descriptions[field.value as keyof typeof descriptions]} +
+ )} + +
+ ) + }} + /> + )} + {uploadEnabled && ( + + {children} + + )} + + + + ) +} diff --git a/apps/ai-hero/src/components/resources-crud/video-upload-form-item.tsx b/apps/ai-hero/src/components/resources-crud/video-upload-form-item.tsx new file mode 100644 index 000000000..90f78af73 --- /dev/null +++ b/apps/ai-hero/src/components/resources-crud/video-upload-form-item.tsx @@ -0,0 +1,87 @@ +import { POST_TYPES_WITH_VIDEO, PostType } from '@/lib/posts' +import { FileVideo } from 'lucide-react' +import { UseFormReturn } from 'react-hook-form' + +import { Button } from '@coursebuilder/ui/primitives/button' +import { + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@coursebuilder/ui/primitives/form' +import { Input } from '@coursebuilder/ui/primitives/input' + +export function VideoUploadFormItem({ + selectedPostType, + form, + videoResourceId, + setVideoResourceId, + children, + handleSetVideoResourceId, + isValidatingVideoResource, + videoResourceValid, +}: { + selectedPostType: PostType + form: UseFormReturn + videoResourceId?: string + setVideoResourceId: (id: string) => void + children: (handleSetVideoResourceId: (id: string) => void) => React.ReactNode + handleSetVideoResourceId: (id: string) => void + isValidatingVideoResource: boolean + videoResourceValid: boolean +}) { + return ( + POST_TYPES_WITH_VIDEO.includes(selectedPostType) && ( + ( + + + {videoResourceId ? 'Video' : 'Upload a Video'} + + + + You can upload a video later if needed. + + Your video will be uploaded and then transcribed automatically. + + + + + {!videoResourceId ? ( + children(handleSetVideoResourceId) + ) : ( +
+
+ + + {videoResourceId} + +
+ +
+ )} + {isValidatingVideoResource && !videoResourceValid ? ( + Processing Upload + ) : null} + + +
+ )} + /> + ) + ) +} diff --git a/apps/ai-hero/src/components/resources-crud/video-uploader.tsx b/apps/ai-hero/src/components/resources-crud/video-uploader.tsx new file mode 100644 index 000000000..4bf5c0893 --- /dev/null +++ b/apps/ai-hero/src/components/resources-crud/video-uploader.tsx @@ -0,0 +1,39 @@ +import * as React from 'react' +import { getUniqueFilename } from '@/utils/get-unique-filename' +import { UploadDropzone } from '@/utils/uploadthing' + +export function VideoUploader({ + setVideoResourceId, + parentResourceId, +}: { + setVideoResourceId: (value: string) => void + parentResourceId?: string +}) { + return ( +
+ { + return files.map( + (file) => + new File([file], getUniqueFilename(file.name), { + type: file.type, + }), + ) + }} + onClientUploadComplete={async (response: any) => { + if (response[0].name) setVideoResourceId(response[0].name) + }} + className="[&_label]:text-primary [&_label]:hover:text-primary border-border" + onUploadError={(error: Error) => { + // Do something with the error. + console.log(`ERROR! ${error.message}`) + }} + /> +
+ ) +} diff --git a/apps/ai-hero/src/lib/discord-query.ts b/apps/ai-hero/src/lib/discord-query.ts index 38402d419..87552e88b 100644 --- a/apps/ai-hero/src/lib/discord-query.ts +++ b/apps/ai-hero/src/lib/discord-query.ts @@ -16,7 +16,7 @@ export async function getDiscordAccount(userId: string) { export async function disconnectDiscord() { const { session } = await getServerAuthSession() - if (!session.user) return false + if (!session?.user) return false const user = await db.query.users.findFirst({ where: eq(users.id, session.user.id), diff --git a/apps/ai-hero/src/lib/posts.ts b/apps/ai-hero/src/lib/posts.ts index 38e9d532d..76ac4cb41 100644 --- a/apps/ai-hero/src/lib/posts.ts +++ b/apps/ai-hero/src/lib/posts.ts @@ -2,7 +2,17 @@ import { z } from 'zod' import { ContentResourceSchema } from '@coursebuilder/core/schemas/content-resource-schema' -import { TagFieldsSchema, TagSchema } from './tags' +export const POST_TYPES_WITH_VIDEO = ['lesson', 'podcast', 'tip'] + +export const PostTypeSchema = z.union([ + z.literal('article'), + z.literal('lesson'), + z.literal('podcast'), + z.literal('tip'), + z.literal('course'), +]) + +export type PostType = z.infer export const PostActionSchema = z.union([ z.literal('publish'), diff --git a/apps/ai-hero/src/lib/progress.ts b/apps/ai-hero/src/lib/progress.ts index 071955b3a..abaeb52af 100644 --- a/apps/ai-hero/src/lib/progress.ts +++ b/apps/ai-hero/src/lib/progress.ts @@ -144,7 +144,7 @@ export async function getModuleProgressForUser( moduleIdOrSlug: string, ): Promise { const { session } = await getServerAuthSession() - if (session.user) { + if (session?.user) { const moduleProgress = await courseBuilderAdapter.getModuleProgressForUser( session.user.id, moduleIdOrSlug, diff --git a/apps/ai-hero/src/uploadthing/core.ts b/apps/ai-hero/src/uploadthing/core.ts index 00a6fe16c..63a8bb381 100644 --- a/apps/ai-hero/src/uploadthing/core.ts +++ b/apps/ai-hero/src/uploadthing/core.ts @@ -19,7 +19,7 @@ export const ourFileRouter = { console.log({ input }) - if (!session.user || !ability.can('create', 'Content')) { + if (!session?.user || !ability.can('create', 'Content')) { throw new Error('Unauthorized') }