Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(vbd): upload and download lesson assets #275

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading