Skip to content

Commit

Permalink
feat: add lesson downloadables
Browse files Browse the repository at this point in the history
  • Loading branch information
zacjones93 committed Aug 26, 2024
1 parent 4b23fd4 commit d28bedd
Show file tree
Hide file tree
Showing 10 changed files with 407 additions and 27 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import { Transcript } from '@/app/(content)/_components/video-transcript-rendere
import { WorkshopPricing } from '@/app/(content)/workshops/_components/workshop-pricing-server'
import { WorkshopResourceList } from '@/app/(content)/workshops/_components/workshop-resource-list'
import Exercise from '@/app/(content)/workshops/[module]/[lesson]/(view)/exercise/_components/exercise'
import { CloudinaryDownloadButton } from '@/components/image-uploader/cloudinary-download-button'
import { PlayerContainerSkeleton } from '@/components/player-skeleton'
import { getAllImageResourcesForResource } from '@/lib/image-resource-query'
import type { Lesson } from '@/lib/lessons'
import {
getLessonMuxPlaybackId,
Expand Down Expand Up @@ -109,7 +111,7 @@ export async function LessonPage({
</AccordionItem>
</Accordion>
<div className="flex flex-col py-5 sm:py-8">
<LessonBody lesson={lesson} />
<LessonBody lesson={lesson} moduleSlug={params.module} />
<TranscriptContainer
className="block 2xl:hidden"
lessonId={lesson?.id}
Expand Down Expand Up @@ -196,11 +198,21 @@ async function PlayerContainer({
)
}

async function LessonBody({ lesson }: { lesson: Lesson | null }) {
async function LessonBody({
lesson,
moduleSlug,
}: {
lesson: Lesson | null
moduleSlug: string
}) {
if (!lesson) {
notFound()
}

const downloadableAssets = await getAllImageResourcesForResource({
resourceId: lesson.id,
})
const abilityLoader = getAbilityForResource(lesson.fields.slug, moduleSlug)
const githubUrl = lesson.fields?.github
const gitpodUrl = lesson.fields?.gitpod

Expand Down Expand Up @@ -269,6 +281,13 @@ async function LessonBody({ lesson }: { lesson: Lesson | null }) {
</Link>
</Button>
)}
{downloadableAssets.length > 0 && (
<CloudinaryDownloadButton
resourceId={lesson.id}
lessonTitle={lesson.fields?.title}
abilityLoader={abilityLoader}
/>
)}
</div>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ import * as React from 'react'
import { useParams } from 'next/navigation'
import { LessonMetadataFormFields } from '@/app/(content)/tutorials/[module]/[lesson]/edit/_components/edit-lesson-form-metadata'
import { onLessonSave } from '@/app/(content)/tutorials/[module]/[lesson]/edit/actions'
import { DownloadableImageResourceUploader } from '@/components/image-uploader/downloadable-image-resource-uploader'
import { env } from '@/env.mjs'
import { useIsMobile } from '@/hooks/use-is-mobile'
import { sendResourceChatMessage } from '@/lib/ai-chat-query'
import { Lesson, LessonSchema } from '@/lib/lessons'
import { updateLesson } from '@/lib/lessons-query'
import { zodResolver } from '@hookform/resolvers/zod'
import { DownloadIcon } from 'lucide-react'
import { useSession } from 'next-auth/react'
import { useTheme } from 'next-themes'
import { useForm, type UseFormReturn } from 'react-hook-form'
Expand Down Expand Up @@ -76,6 +78,22 @@ export function EditLessonForm({ lesson }: Omit<EditLessonFormProps, 'form'>) {
user={session?.data?.user}
onSave={onLessonSaveWithModule}
theme={theme}
tools={[
{
id: 'downloadable-media',
label: 'Downloadable Media',
icon: () => (
<DownloadIcon strokeWidth={1.5} size={24} width={18} height={18} />
),
toolComponent: (
<DownloadableImageResourceUploader
key={'downloadable-image-uploader'}
belongsToResourceId={lesson.id}
uploadDirectory={`lessons`}
/>
),
},
]}
>
<LessonMetadataFormFields
initialVideoResourceId={initialVideoResourceId}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
'use client'

import { use } from 'react'
import { api } from '@/trpc/react'
import { cn } from '@/utils/cn'
import { AbilityForResource } from '@/utils/get-current-ability-rules'
import { TRPCError } from '@trpc/server'
import { Download, Lock } from 'lucide-react'

import { Button } from '@coursebuilder/ui'

export const CloudinaryDownloadButton: React.FC<{
resourceId: string
lessonTitle: string
abilityLoader?: Promise<AbilityForResource>
}> = ({ resourceId, lessonTitle, abilityLoader }) => {
const ability = abilityLoader ? use(abilityLoader) : null
const canView = ability?.canView

const { mutateAsync: download, status } =
api.imageResources.download.useMutation({
onSuccess: (response) => {
if (response instanceof TRPCError) {
throw new Error('Sorry, I could not find that file.')
}

const byteCharacters = atob(response.data)
const byteNumbers = new Array(byteCharacters.length)
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i)
}
const byteArray = new Uint8Array(byteNumbers)
const blob = new Blob([byteArray], { type: 'application/zip' })

// Create download link and trigger download
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = response.fileName
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
},
})

const downloadButton = canView ? (
<>
<Download className="h-5 w-5" /> Download
</>
) : (
<>
<Lock className="h-5 w-5" /> Download
</>
)

return (
<Button
onClick={() => {
download({ resourceId, lessonTitle })
}}
variant="outline"
className={cn(
'relative h-full w-full cursor-pointer',
!canView && 'cursor-not-allowed opacity-50',
)}
disabled={!canView || status === 'pending'}
>
<div
className={cn(
'inset-0 flex items-center justify-center gap-2 transition-all duration-300 ease-in-out',
status === 'pending' ? 'opacity-0' : 'opacity-100',
)}
>
{downloadButton}
</div>
<div
className={cn(
'absolute inset-0 flex items-center justify-center gap-2 transition-all duration-300 ease-in-out',
status === 'pending' ? 'opacity-100' : 'opacity-0',
)}
>
<Spinner className="h-5 w-5" />
</div>
</Button>
)
}

const Spinner: React.FunctionComponent<{
className?: string
}> = ({ className = 'w-8 h-8', ...rest }) => (
<svg
className={cn('animate-spin', className)}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
{...rest}
>
<title>Loading</title>
<circle
opacity={0.25}
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
opacity={0.75}
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
)
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ import { useSession } from 'next-auth/react'

import { Button } from '@coursebuilder/ui'

export const CloudinaryUploadButton: React.FC<{ dir: string; id: string }> = ({
dir,
id,
}) => {
export const CloudinaryUploadButton: React.FC<{
dir: string
id: string
is_downloadable?: boolean
resourceOfId?: string
}> = ({ dir, id, is_downloadable = false, resourceOfId }) => {
const session = useSession()
const cloudinaryRef = React.useRef<any>()
const widgetRef = React.useRef<any>()
Expand All @@ -20,6 +22,8 @@ export const CloudinaryUploadButton: React.FC<{ dir: string; id: string }> = ({
cloudinaryRef.current = (window as any).cloudinary
}, [])

const folder = is_downloadable ? `${dir}/${id}/downloadables` : `${dir}/${id}`

return session?.data?.user ? (
<div>
<Script
Expand All @@ -41,14 +45,17 @@ export const CloudinaryUploadButton: React.FC<{ dir: string; id: string }> = ({
cloudName: env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME,
uploadPreset: env.NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET,
// inline_container: '#cloudinary-upload-widget-container',
folder: `${dir}/${id}`,
folder,
},
(error: any, result: any) => {
if (!error && result && result.event === 'success') {
console.debug('Done! Here is the image info: ', result.info)
createImageResource({
asset_id: result.info.asset_id,
secure_url: result.info.url,
public_id: result.info.public_id,
is_downloadable,
resourceOfId: id,
})
}
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import React from 'react'
import { useSocket } from '@/hooks/use-socket'
import { api } from '@/trpc/react'

export const DownloadableImageResourceBrowser = ({
resourceId,
}: {
resourceId: string
}) => {
const { data: images = [], refetch } =
api.imageResources.getAllForResource.useQuery({ resourceId })

useSocket({
onMessage: (messageEvent) => {
try {
const messageData = JSON.parse(messageEvent.data)
if (messageData.name === 'image.resource.created') {
refetch()
}
} catch (error) {
// noting to do
}
},
})

return (
<div>
<h3 className="inline-flex px-5 text-lg font-bold">Media Browser</h3>
<div className="grid grid-cols-3 gap-1 px-5">
{images.map((asset) => {
return asset?.url ? (
<div
key={asset.id}
className="flex items-center justify-center overflow-hidden rounded border"
>
<img
src={asset.url}
alt={asset.id}
onDragStart={(e) => {
e.dataTransfer.setData(
'text/plain',
`![](${e.currentTarget.src})`,
)
}}
/>
</div>
) : null
})}
</div>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import * as React from 'react'
import { CloudinaryUploadButton } from '@/components/image-uploader/cloudinary-upload-button'
import { DownloadableImageResourceBrowser } from '@/components/image-uploader/downloadable-image-resource-browser'

import { ScrollArea } from '@coursebuilder/ui'

export function DownloadableImageResourceUploader(props: {
uploadDirectory: string
belongsToResourceId: string
}) {
return (
<>
<div>
<h2>Downloadable Media</h2>
</div>
<ScrollArea className="h-[var(--pane-layout-height)] overflow-y-auto">
<CloudinaryUploadButton
dir={props.uploadDirectory}
id={props.belongsToResourceId}
is_downloadable={true}
/>
<React.Suspense fallback={<div>Loading...</div>}>
<DownloadableImageResourceBrowser
resourceId={props.belongsToResourceId}
/>
</React.Suspense>
</ScrollArea>
</>
)
}
4 changes: 4 additions & 0 deletions apps/value-based-design/src/env.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ export const env = createEnv({
POSTMARK_KEY: z.string().optional(),
SLACK_TOKEN: z.string().optional(),
SLACK_DEFAULT_CHANNEL_ID: z.string().optional(),
CLOUDINARY_API_SECRET: z.string().optional(),
CLOUDINARY_API_KEY: z.string().optional(),
},

/**
Expand Down Expand Up @@ -126,6 +128,8 @@ export const env = createEnv({
process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME,
NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET:
process.env.NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET,
CLOUDINARY_API_SECRET: process.env.CLOUDINARY_API_SECRET,
CLOUDINARY_API_KEY: process.env.CLOUDINARY_API_KEY,
TWITTER_CLIENT_ID: process.env.TWITTER_CLIENT_ID,
TWITTER_CLIENT_SECRET: process.env.TWITTER_CLIENT_SECRET,
UPSTASH_REDIS_REST_URL: process.env.UPSTASH_REDIS_REST_URL,
Expand Down
Loading

0 comments on commit d28bedd

Please sign in to comment.