From 00aa5d30b8d4e2c56866673da218947675d6e504 Mon Sep 17 00:00:00 2001 From: Rajat Saxena Date: Sun, 2 Nov 2025 23:18:20 +0530 Subject: [PATCH 1/4] Large file uploads Fixes #657 --- apps/docs/public/assets/schools/self-host.svg | 3 + .../src/pages/en/self-hosting/introduction.md | 4 + apps/web/app/api/media/presigned/route.ts | 18 +- apps/web/next-env.d.ts | 1 + apps/web/package.json | 1 + packages/components-library/package.json | 7 +- .../src/components/ui/alert-dialog.tsx | 141 +++++++ .../src/components/ui/input.tsx | 7 +- .../src/components/ui/progress.tsx | 26 ++ .../src/media-selector/file-upload-dialog.tsx | 351 ++++++++++++++++++ .../src/media-selector/index.tsx | 90 +---- .../src/media-selector/type.ts | 10 + pnpm-lock.yaml | 142 ++++++- 13 files changed, 715 insertions(+), 86 deletions(-) create mode 100644 apps/docs/public/assets/schools/self-host.svg create mode 100644 packages/components-library/src/components/ui/alert-dialog.tsx create mode 100644 packages/components-library/src/components/ui/progress.tsx create mode 100644 packages/components-library/src/media-selector/file-upload-dialog.tsx create mode 100644 packages/components-library/src/media-selector/type.ts diff --git a/apps/docs/public/assets/schools/self-host.svg b/apps/docs/public/assets/schools/self-host.svg new file mode 100644 index 000000000..5264426ad --- /dev/null +++ b/apps/docs/public/assets/schools/self-host.svg @@ -0,0 +1,3 @@ +CourseLitMediaLitYour VPS serverBrowserSchool dataMedia filesschool.commedia.school.comCourseLitMediaLitYour VPS serverBrowserSchool dataMedia filesschool.commedialit.cloudMediaLit.cloud serverOption A - You Control EverythingOption B - You Control School, We control Media Handling \ No newline at end of file diff --git a/apps/docs/src/pages/en/self-hosting/introduction.md b/apps/docs/src/pages/en/self-hosting/introduction.md index 5613f3bd4..8d98e375a 100644 --- a/apps/docs/src/pages/en/self-hosting/introduction.md +++ b/apps/docs/src/pages/en/self-hosting/introduction.md @@ -15,6 +15,10 @@ You should self host CourseLit, if you: - want complete control of your data - want to host it behind a firewall for internal use +## How to self host? + +![Self hosting options](/assets/schools/self-host.svg) + ### Self host CourseLit See [the self hosting guide](/en/self-hosting/self-host). diff --git a/apps/web/app/api/media/presigned/route.ts b/apps/web/app/api/media/presigned/route.ts index d11d31046..425c4b61b 100644 --- a/apps/web/app/api/media/presigned/route.ts +++ b/apps/web/app/api/media/presigned/route.ts @@ -1,12 +1,17 @@ import { NextRequest } from "next/server"; import { responses } from "@/config/strings"; -import * as medialitService from "@/services/medialit"; import { UIConstants as constants } from "@courselit/common-models"; import { checkPermission } from "@courselit/utils"; import User from "@models/User"; import DomainModel, { Domain } from "@models/Domain"; import { auth } from "@/auth"; import { error } from "@/services/logger"; +import { MediaLit } from "medialit"; + +const medialit = new MediaLit({ + apiKey: process.env.MEDIALIT_APIKEY, + endpoint: process.env.MEDIALIT_SERVER +}) export async function POST(req: NextRequest) { const domain = await DomainModel.findOne({ @@ -41,10 +46,13 @@ export async function POST(req: NextRequest) { } try { - let response = await medialitService.getPresignedUrlForUpload( - domain.name, - ); - return Response.json({ url: response }); + let signature = await medialit.getSignature({ + group: domain.name + }) + return Response.json({ + signature, + endpoint: medialit.endpoint + }); } catch (err: any) { error(err.message, { stack: err.stack, diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index 830fb594c..36a4fe488 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -1,5 +1,6 @@ /// /// +/// /// // NOTE: This file should not be edited diff --git a/apps/web/package.json b/apps/web/package.json index 3837eb127..3672ebdf1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -57,6 +57,7 @@ "jsdom": "^26.1.0", "lodash.debounce": "^4.0.8", "lucide-react": "^0.544.0", + "medialit": "^0.1.0", "mongodb": "^6.15.0", "mongoose": "^8.13.1", "next": "^15.5.4", diff --git a/packages/components-library/package.json b/packages/components-library/package.json index fb16dc148..1fc296d7f 100644 --- a/packages/components-library/package.json +++ b/packages/components-library/package.json @@ -61,6 +61,7 @@ "@dnd-kit/sortable": "^8.0.0", "@dnd-kit/utilities": "^3.2.2", "@radix-ui/react-accordion": "^1.1.2", + "@radix-ui/react-alert-dialog": "^1.1.11", "@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-dialog": "^1.1.14", @@ -68,10 +69,11 @@ "@radix-ui/react-form": "^0.0.3", "@radix-ui/react-label": "^2.1.2", "@radix-ui/react-popover": "^1.0.7", + "@radix-ui/react-progress": "^1.1.7", "@radix-ui/react-scroll-area": "^1.0.5", "@radix-ui/react-select": "^2.1.6", "@radix-ui/react-slider": "^1.1.2", - "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.1.3", "@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-toast": "^1.2.2", @@ -83,6 +85,7 @@ "lucide-react": "^0.309.0", "react-dom": "^18.2.0", "tailwind-merge": "^2.2.0", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "tus-js-client": "^4.3.1" } } diff --git a/packages/components-library/src/components/ui/alert-dialog.tsx b/packages/components-library/src/components/ui/alert-dialog.tsx new file mode 100644 index 000000000..5b1b44965 --- /dev/null +++ b/packages/components-library/src/components/ui/alert-dialog.tsx @@ -0,0 +1,141 @@ +"use client"; + +import * as React from "react"; +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; + +import { cn } from "@/lib/utils"; +import { buttonVariants } from "@/components/ui/button"; + +const AlertDialog = AlertDialogPrimitive.Root; + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger; + +const AlertDialogPortal = AlertDialogPrimitive.Portal; + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)); +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogHeader.displayName = "AlertDialogHeader"; + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogFooter.displayName = "AlertDialogFooter"; + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName; + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +}; diff --git a/packages/components-library/src/components/ui/input.tsx b/packages/components-library/src/components/ui/input.tsx index b4281a6c4..fac25a2c8 100644 --- a/packages/components-library/src/components/ui/input.tsx +++ b/packages/components-library/src/components/ui/input.tsx @@ -2,16 +2,13 @@ import * as React from "react"; import { cn } from "@/lib/utils"; -export interface InputProps - extends React.InputHTMLAttributes {} - -const Input = React.forwardRef( +const Input = React.forwardRef>( ({ className, type, ...props }, ref) => { return ( , + React.ComponentPropsWithoutRef +>(({ className, value, ...props }, ref) => ( + + + +)); +Progress.displayName = ProgressPrimitive.Root.displayName; + +export { Progress }; diff --git a/packages/components-library/src/media-selector/file-upload-dialog.tsx b/packages/components-library/src/media-selector/file-upload-dialog.tsx new file mode 100644 index 000000000..f6bcadb0b --- /dev/null +++ b/packages/components-library/src/media-selector/file-upload-dialog.tsx @@ -0,0 +1,351 @@ +import type React from "react"; + +import { useState, useRef } from "react"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Progress } from "@/components/ui/progress"; +import { Upload } from "lucide-react"; +import { FetchBuilder } from "@courselit/utils"; +import { Address, Media } from "@courselit/common-models"; +import { useToast } from "@/hooks/use-toast"; +import Access from "./access"; +import { Upload as TUSUpload } from "tus-js-client"; +import MediaType from "./type"; +import { AlertDialogAction } from "@radix-ui/react-alert-dialog"; + +interface FileUploadAlertDialogProps { + acceptedMimeTypes?: string[]; + disabled?: boolean; + address: Address; + access: Access; + type: MediaType; + onSuccess: (media: Media) => void; + open: boolean; + setOpen: (value: boolean) => void; +} + +export function FileUploadAlertDialog({ + acceptedMimeTypes = [], + disabled = false, + address, + access, + type, + onSuccess, + open, + setOpen, +}: FileUploadAlertDialogProps) { + const [file, setFile] = useState(null); + const [caption, setCaption] = useState(""); + const [isDragging, setIsDragging] = useState(false); + const [isUploading, setIsUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(0); + const [fileError, setFileError] = useState(""); + const fileInputRef = useRef(null); + const { toast } = useToast(); + const uploadRef = useRef(null); + + const isValidMimeType = (mimeType: string): boolean => { + if (acceptedMimeTypes.length === 0) return true; + return acceptedMimeTypes.includes(mimeType); + }; + + const handleFileValidation = (selectedFile: File) => { + if (!isValidMimeType(selectedFile.type)) { + setFileError( + `Invalid file type. Accepted types: ${acceptedMimeTypes.join(", ")}`, + ); + setFile(null); + return; + } + setFileError(""); + setFile(selectedFile); + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(true); + }; + + const handleDragLeave = () => { + setIsDragging(false); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + const droppedFiles = e.dataTransfer.files; + if (droppedFiles.length > 0) { + handleFileValidation(droppedFiles[0]); + } + }; + + const handleFileChange = (e: React.ChangeEvent) => { + if (e.target.files && e.target.files.length > 0) { + handleFileValidation(e.target.files[0]); + } + }; + + const handleUpload = async () => { + if (file) { + setIsUploading(true); + setUploadProgress(0); + + try { + const { signature, endpoint } = await getSignature(); + + if (!signature || !endpoint) { + toast({ + title: "Error", + description: "Failed to get signature", + variant: "destructive", + }); + } + const uploadUrl = `${endpoint}/media/create/resumable`; + const metadata = { + fileName: file.name, + mimeType: file.type, + access, + caption: caption || "", + }; + const upload = new TUSUpload(file, { + endpoint: uploadUrl, + // chunkSize: 1024000, // 10 MB + removeFingerprintOnSuccess: true, + retryDelays: [0, 3000, 5000], + headers: { + "x-medialit-signature": signature, + }, + metadata, + onProgress: (bytesUploaded, bytesTotal) => { + const percentage = (bytesUploaded / bytesTotal) * 100; + setUploadProgress(percentage); + }, + onError: (error) => { + toast({ + title: "Error", + description: error.message, + variant: "destructive", + }); + setIsUploading(false); + }, + onSuccess: async (payload) => { + const mediaString = + payload.lastResponse.getHeader("Media"); + const media: Media = mediaString + ? JSON.parse(mediaString) + : null; + if (media) { + media && onSuccess(media); + + setOpen(false); + setFile(null); + setCaption(""); + setUploadProgress(0); + setIsUploading(false); + } + }, + }); + uploadRef.current = upload; + + upload.findPreviousUploads().then(function (previousUploads) { + if (previousUploads.length) { + upload.resumeFromPreviousUpload(previousUploads[0]); + } + + upload.start(); + }); + } catch (error) { + toast({ + title: "Error", + description: error.message, + variant: "destructive", + }); + setIsUploading(false); + } + } + }; + + const getMedia = async (mediaId: string) => { + const fetch = new FetchBuilder() + .setUrl(`${address.backend}/api/media/${mediaId}/${type}`) + .setHttpMethod("GET") + .setIsGraphQLEndpoint(false) + .build(); + return await fetch.exec(); + }; + + const handleReset = () => { + if (uploadRef.current) { + uploadRef.current.abort(); + uploadRef.current = null; + } + setFile(null); + setCaption(""); + setUploadProgress(0); + setFileError(""); + setOpen(false); + setIsUploading(false); + }; + + const getSignature = async () => { + const fetch = new FetchBuilder() + .setUrl(`${address.backend}/api/media/presigned`) + .setIsGraphQLEndpoint(false) + .build(); + return await fetch.exec(); + }; + + const acceptAttribute = + acceptedMimeTypes.length > 0 ? acceptedMimeTypes.join(",") : undefined; + + return ( + + + + + + + Upload File + + Drag and drop your file or click to browse + + + +
+
fileInputRef.current?.click()} + className={`relative flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed px-6 py-8 transition-all duration-200 ${ + isDragging + ? "border-primary bg-primary/5" + : "border-muted-foreground/25 hover:border-muted-foreground/50" + } ${file ? "bg-primary/5" : ""} ${fileError ? "border-destructive bg-destructive/5" : ""}`} + style={{ pointerEvents: isUploading ? "none" : "auto" }} + > + + +

+ {file ? file.name : "Drop file here or click"} +

+ {file && !fileError && ( +

{`Selected: ${(file.size / 1024).toFixed(2)} KB`}

+ )} + {fileError && ( +

+ {fileError} +

+ )} +
+ +
+ + setCaption(e.target.value)} + className="resize-none" + disabled={isUploading} + /> +
+ + {isUploading && ( +
+
+
+ +
+

+ {file?.name} +

+

+ {Math.round(uploadProgress)}% +

+
+
+
+ +
+ )} +
+ + {isUploading ? ( + { + if (uploadRef.current) { + uploadRef.current.abort(); + uploadRef.current = null; + } + setIsUploading(false); + // setUploadProgress(0); + }} + disabled={Math.round(uploadProgress) > 99} + > + {Math.round(uploadProgress) > 99 + ? "Processing..." + : "Cancel"} + + ) : ( + <> + + Cancel + + + + + + )} + +
+
+ ); +} diff --git a/packages/components-library/src/media-selector/index.tsx b/packages/components-library/src/media-selector/index.tsx index cfa68cbc9..0dc5680f4 100644 --- a/packages/components-library/src/media-selector/index.tsx +++ b/packages/components-library/src/media-selector/index.tsx @@ -1,16 +1,15 @@ "use client"; -import { ChangeEvent, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { Image } from "../image"; import { Address, Media, Profile } from "@courselit/common-models"; import Access from "./access"; -import Dialog2 from "../dialog2"; import { FetchBuilder } from "@courselit/utils"; -import Form from "../form"; -import FormField from "../form-field"; import React from "react"; import { Button2, PageBuilderPropertyHeader, Tooltip, useToast } from ".."; import { X } from "lucide-react"; +import { FileUploadAlertDialog } from "./file-upload-dialog"; +import MediaType from "./type"; interface Strings { buttonCaption?: string; @@ -54,14 +53,7 @@ interface MediaSelectorProps { access?: Access; strings: Strings; mediaId?: string; - type: - | "course" - | "lesson" - | "page" - | "user" - | "domain" - | "community" - | "certificate"; + type: MediaType; hidePreview?: boolean; tooltip?: string; disabled?: boolean; @@ -96,6 +88,8 @@ const MediaSelector = (props: MediaSelectorProps) => { variant: "destructive", }); }, + access, + type, } = props; const onSelection = (media: Media) => { @@ -107,8 +101,8 @@ const MediaSelector = (props: MediaSelectorProps) => { .setUrl(`${address.backend}/api/media/presigned`) .setIsGraphQLEndpoint(false) .build(); - const response = await fetch.exec(); - return response.url; + const { endpoint, signature } = await fetch.exec(); + return `${endpoint}/media/create?signature=${signature}`; }; useEffect(() => { @@ -228,66 +222,16 @@ const MediaSelector = (props: MediaSelectorProps) => { )} {!props.mediaId && (
- - {strings.buttonCaption || "Select media"} - - } + - {uploading - ? strings.uploading || "Uploading" - : strings.uploadButtonText || "Upload"} - - } - > - {error &&
{error}
} -
- - setSelectedFile(e.target.files[0]) - } - messages={[ - { - match: "valueMissing", - text: "File is required", - }, - ]} - disabled={selectedFile && uploading} - className="mt-2" - required - /> - , - ) => setCaption(e.target.value)} - rows={5} - disabled={selectedFile && uploading} - /> - -
+ setOpen={setDialogOpened} + />
)}
diff --git a/packages/components-library/src/media-selector/type.ts b/packages/components-library/src/media-selector/type.ts new file mode 100644 index 000000000..3bc047c56 --- /dev/null +++ b/packages/components-library/src/media-selector/type.ts @@ -0,0 +1,10 @@ +type MediaType = + | "course" + | "lesson" + | "page" + | "user" + | "domain" + | "community" + | "certificate"; + +export default MediaType; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5e622b3b1..8cc11cb11 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -329,6 +329,9 @@ importers: lucide-react: specifier: ^0.544.0 version: 0.544.0(react@18.3.1) + medialit: + specifier: ^0.1.0 + version: 0.1.0 mongodb: specifier: ^6.15.0 version: 6.16.0(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4) @@ -545,6 +548,9 @@ importers: '@radix-ui/react-accordion': specifier: ^1.1.2 version: 1.2.8(@types/react-dom@18.3.6(@types/react@18.3.20))(@types/react@18.3.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-alert-dialog': + specifier: ^1.1.11 + version: 1.1.11(@types/react-dom@18.3.6(@types/react@18.3.20))(@types/react@18.3.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-avatar': specifier: ^1.0.4 version: 1.1.7(@types/react-dom@18.3.6(@types/react@18.3.20))(@types/react@18.3.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -566,6 +572,9 @@ importers: '@radix-ui/react-popover': specifier: ^1.0.7 version: 1.1.14(@types/react-dom@18.3.6(@types/react@18.3.20))(@types/react@18.3.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-progress': + specifier: ^1.1.7 + version: 1.1.7(@types/react-dom@18.3.6(@types/react@18.3.20))(@types/react@18.3.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-scroll-area': specifier: ^1.0.5 version: 1.2.6(@types/react-dom@18.3.6(@types/react@18.3.20))(@types/react@18.3.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -576,7 +585,7 @@ importers: specifier: ^1.1.2 version: 1.3.2(@types/react-dom@18.3.6(@types/react@18.3.20))(@types/react@18.3.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-slot': - specifier: ^1.1.2 + specifier: ^1.2.3 version: 1.2.3(@types/react@18.3.20)(react@18.3.1) '@radix-ui/react-switch': specifier: ^1.1.3 @@ -620,6 +629,9 @@ importers: tailwindcss-animate: specifier: ^1.0.7 version: 1.0.7(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@20.19.0)(typescript@4.9.5))) + tus-js-client: + specifier: ^4.3.1 + version: 4.3.1 devDependencies: '@types/react': specifier: ^18.0.0 @@ -3326,6 +3338,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-progress@1.1.7': + resolution: {integrity: sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==} + peerDependencies: + '@types/react': ^18.0.0 + '@types/react-dom': '*' + react: ^18.3.1 + react-dom: ^18.3.1 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-radio-group@1.3.4': resolution: {integrity: sha512-N4J9QFdW5zcJNxxY/zwTXBN4Uc5VEuRM7ZLjNfnWoKmNvgrPtNNw4P8zY532O3qL6aPkaNO+gY9y6bfzmH4U1g==} peerDependencies: @@ -5645,6 +5670,9 @@ packages: colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + combine-errors@3.0.3: + resolution: {integrity: sha512-C8ikRNRMygCwaTx+Ek3Yr+OuZzgZjduCOfSQBjbM8V3MfgcjSTeto/GXP6PAwKvJz/v15b7GHZvx5rOlczFw/Q==} + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -5794,6 +5822,9 @@ packages: currency-symbol-map@5.1.0: resolution: {integrity: sha512-LO/lzYRw134LMDVnLyAf1dHE5tyO6axEFkR3TXjQIOmMkAM9YL6QsiUwuXzZAmFnuDJcs4hayOgyIYtViXFrLw==} + custom-error-instance@2.1.1: + resolution: {integrity: sha512-p6JFxJc3M4OTD2li2qaHkDCw9SfMw82Ldr6OC9Je1aXiGfhx2W8p3GaoeaGrPJTUN9NirTM/KTxHWMUdR1rsUg==} + d3-array@3.2.4: resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} engines: {node: '>=12'} @@ -7515,6 +7546,9 @@ packages: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} + js-base64@3.7.8: + resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==} + js-sha256@0.10.1: resolution: {integrity: sha512-5obBtsz9301ULlsgggLg542s/jqtddfOpV5KJc4hajc9JV8GeY2gZHSVpYBn4nWqAUTJ9v+xwtbJ1mIBgIH5Vw==} @@ -7697,6 +7731,24 @@ packages: lodash-es@4.17.21: resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} + lodash._baseiteratee@4.7.0: + resolution: {integrity: sha512-nqB9M+wITz0BX/Q2xg6fQ8mLkyfF7MU7eE+MNBNjTHFKeKaZAPEzEg+E8LWxKWf1DQVflNEn9N49yAuqKh2mWQ==} + + lodash._basetostring@4.12.0: + resolution: {integrity: sha512-SwcRIbyxnN6CFEEK4K1y+zuApvWdpQdBHM/swxP962s8HIxPO3alBH5t3m/dl+f4CMUug6sJb7Pww8d13/9WSw==} + + lodash._baseuniq@4.6.0: + resolution: {integrity: sha512-Ja1YevpHZctlI5beLA7oc5KNDhGcPixFhcqSiORHNsp/1QTv7amAXzw+gu4YOvErqVlMVyIJGgtzeepCnnur0A==} + + lodash._createset@4.0.3: + resolution: {integrity: sha512-GTkC6YMprrJZCYU3zcqZj+jkXkrXzq3IPBcF/fIPpNEAB4hZEtXU8zp/RwKOvZl43NUmwDbyRk3+ZTbeRdEBXA==} + + lodash._root@3.0.1: + resolution: {integrity: sha512-O0pWuFSK6x4EXhM1dhZ8gchNtG7JMqBtrHdoUFUWXD7dJnNSUze1GuyQr5sOs0aCvgGeI3o/OJW8f4ca7FDxmQ==} + + lodash._stringtopath@4.8.0: + resolution: {integrity: sha512-SXL66C731p0xPDC5LZg4wI5H+dJo/EO4KTqOMwLYCH3+FmmfAKJEZCm6ohGpI+T1xwsDsJCfL4OnhorllvlTPQ==} + lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} @@ -7745,9 +7797,15 @@ packages: lodash.startcase@4.4.0: resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} + lodash.throttle@4.1.1: + resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==} + lodash.union@4.6.0: resolution: {integrity: sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==} + lodash.uniqby@4.5.0: + resolution: {integrity: sha512-IRt7cfTtHy6f1aRVA5n7kT8rgN3N1nH6MOWLcHfpWG2SH19E3JksLK38MktLxZDhlAjCP9jpIXkOnRXlu6oByQ==} + lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} @@ -7897,6 +7955,10 @@ packages: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} + medialit@0.1.0: + resolution: {integrity: sha512-J9Vc1jWYwvCECB6uYm50MZ5dJKneULqdlD9PP1ArhFwrPX0KXWNaJo2JyZSiTSrJfLQJLWdujROK4qLw0co5UQ==} + engines: {node: '>=18.0.0'} + memory-pager@1.5.0: resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==} @@ -8731,6 +8793,9 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + property-information@5.6.0: resolution: {integrity: sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA==} @@ -9146,6 +9211,10 @@ packages: retext@8.1.0: resolution: {integrity: sha512-N9/Kq7YTn6ZpzfiGW45WfEGJqFf1IM1q8OsRa1CGzIebCJBNCANDRmOrholiDRGKo/We7ofKR4SEvcGAWEMD3Q==} + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -9839,6 +9908,10 @@ packages: turndown@7.2.0: resolution: {integrity: sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A==} + tus-js-client@4.3.1: + resolution: {integrity: sha512-ZLeYmjrkaU1fUsKbIi8JML52uAocjEZtBx4DKjRrqzrZa0O4MYwT6db+oqePlspV+FxXJAyFBc/L5gwUi2OFsg==} + engines: {node: '>=18'} + tw-animate-css@1.2.8: resolution: {integrity: sha512-AxSnYRvyFnAiZCUndS3zQZhNfV/B77ZhJ+O7d3K6wfg/jKJY+yv6ahuyXwnyaYA9UdLqnpCwhTRv9pPTBnPR2g==} @@ -13288,6 +13361,16 @@ snapshots: '@types/react': 18.3.20 '@types/react-dom': 18.3.6(@types/react@18.3.20) + '@radix-ui/react-progress@1.1.7(@types/react-dom@18.3.6(@types/react@18.3.20))(@types/react@18.3.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-context': 1.1.2(@types/react@18.3.20)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.6(@types/react@18.3.20))(@types/react@18.3.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.20 + '@types/react-dom': 18.3.6(@types/react@18.3.20) + '@radix-ui/react-radio-group@1.3.4(@types/react-dom@18.3.6(@types/react@18.3.20))(@types/react@18.3.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.2 @@ -17410,6 +17493,11 @@ snapshots: colorette@2.0.20: {} + combine-errors@3.0.3: + dependencies: + custom-error-instance: 2.1.1 + lodash.uniqby: 4.5.0 + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 @@ -17557,6 +17645,8 @@ snapshots: currency-symbol-map@5.1.0: {} + custom-error-instance@2.1.1: {} + d3-array@3.2.4: dependencies: internmap: 2.0.3 @@ -19838,6 +19928,8 @@ snapshots: joycon@3.1.1: {} + js-base64@3.7.8: {} + js-sha256@0.10.1: {} js-stringify@1.0.2: {} @@ -20066,6 +20158,25 @@ snapshots: lodash-es@4.17.21: {} + lodash._baseiteratee@4.7.0: + dependencies: + lodash._stringtopath: 4.8.0 + + lodash._basetostring@4.12.0: {} + + lodash._baseuniq@4.6.0: + dependencies: + lodash._createset: 4.0.3 + lodash._root: 3.0.1 + + lodash._createset@4.0.3: {} + + lodash._root@3.0.1: {} + + lodash._stringtopath@4.8.0: + dependencies: + lodash._basetostring: 4.12.0 + lodash.debounce@4.0.8: {} lodash.defaults@4.2.0: {} @@ -20098,8 +20209,15 @@ snapshots: lodash.startcase@4.4.0: {} + lodash.throttle@4.1.1: {} + lodash.union@4.6.0: {} + lodash.uniqby@4.5.0: + dependencies: + lodash._baseiteratee: 4.7.0 + lodash._baseuniq: 4.6.0 + lodash@4.17.21: {} log-symbols@5.1.0: @@ -20315,6 +20433,10 @@ snapshots: media-typer@0.3.0: {} + medialit@0.1.0: + dependencies: + form-data: 4.0.2 + memory-pager@1.5.0: {} merge-descriptors@1.0.3: {} @@ -21294,6 +21416,12 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + property-information@5.6.0: dependencies: xtend: 4.0.2 @@ -22016,6 +22144,8 @@ snapshots: retext-stringify: 3.1.0 unified: 10.1.2 + retry@0.12.0: {} + reusify@1.1.0: {} rfdc@1.4.1: {} @@ -22925,6 +23055,16 @@ snapshots: dependencies: '@mixmark-io/domino': 2.2.0 + tus-js-client@4.3.1: + dependencies: + buffer-from: 1.1.2 + combine-errors: 3.0.3 + is-stream: 2.0.1 + js-base64: 3.7.8 + lodash.throttle: 4.1.1 + proper-lockfile: 4.1.2 + url-parse: 1.5.10 + tw-animate-css@1.2.8: {} type-check@0.4.0: From 30a3a792280d8ddd6cdc0476edbfe46c5398cbd3 Mon Sep 17 00:00:00 2001 From: Rajat Saxena Date: Mon, 3 Nov 2025 09:05:41 +0530 Subject: [PATCH 2/4] Text editor image upload ported to new medialit api --- .../src/media-selector/file-upload-dialog.tsx | 9 --- packages/text-editor/src/extensions.ts | 72 +------------------ .../text-editor/src/file-upload-extention.ts | 72 +++++++++++++++++++ 3 files changed, 73 insertions(+), 80 deletions(-) create mode 100644 packages/text-editor/src/file-upload-extention.ts diff --git a/packages/components-library/src/media-selector/file-upload-dialog.tsx b/packages/components-library/src/media-selector/file-upload-dialog.tsx index f6bcadb0b..1dc9edd37 100644 --- a/packages/components-library/src/media-selector/file-upload-dialog.tsx +++ b/packages/components-library/src/media-selector/file-upload-dialog.tsx @@ -175,15 +175,6 @@ export function FileUploadAlertDialog({ } }; - const getMedia = async (mediaId: string) => { - const fetch = new FetchBuilder() - .setUrl(`${address.backend}/api/media/${mediaId}/${type}`) - .setHttpMethod("GET") - .setIsGraphQLEndpoint(false) - .build(); - return await fetch.exec(); - }; - const handleReset = () => { if (uploadRef.current) { uploadRef.current.abort(); diff --git a/packages/text-editor/src/extensions.ts b/packages/text-editor/src/extensions.ts index 4645f6ca7..431a2b4a5 100644 --- a/packages/text-editor/src/extensions.ts +++ b/packages/text-editor/src/extensions.ts @@ -3,7 +3,6 @@ import { DocExtension, DropCursorExtension, HeadingExtension, - ImageAttributes, ImageExtension, LinkExtension, OrderedListExtension, @@ -30,75 +29,7 @@ import { CodeMirrorExtension } from "@remirror/extension-codemirror6"; import { TableExtension } from "@remirror/extension-react-tables"; import { oneDark } from "@codemirror/theme-one-dark"; import { basicSetup } from "codemirror"; -import { DelayedPromiseCreator, ErrorConstant, invariant } from "remirror"; -import { FetchBuilder } from "@courselit/utils"; -import { Media } from "@courselit/common-models"; - -// const wysiwygPresetArrayWithoutImageExtension = wysiwygPreset().filter( -// (extension) => extension instanceof ImageExtension !== true, -// ); - -type SetProgress = (progress: number) => void; - -interface FileWithProgress { - file: File; - progress: SetProgress; -} - -async function getPresignedUrl(url: string) { - const fetch = new FetchBuilder() - .setUrl(`${url}/api/media/presigned`) - .setIsGraphQLEndpoint(false) - .build(); - const response = await fetch.exec(); - return response.url; -} - -function getUploadHandler(url: string) { - return function uploadFileToMediaLit( - files: FileWithProgress[], - ): DelayedPromiseCreator[] { - invariant(files.length > 0, { - code: ErrorConstant.EXTENSION, - message: - "The upload handler was applied for the image extension without any valid files", - }); - - let completed = 0; - const promises: DelayedPromiseCreator[] = []; - - for (const { file, progress } of files) { - promises.push( - () => - new Promise((resolve, reject) => { - getPresignedUrl(url) - .then((presignedUrl) => { - const fD = new FormData(); - fD.append("caption", file.name); - fD.append("access", "public"); - fD.append("file", file); - - return fetch(presignedUrl, { - method: "POST", - body: fD, - }); - }) - .then((data) => data.json()) - .then((data: Media) => { - completed += 1; - progress(completed / files.length); - resolve({ - src: data.file, - fileName: data.originalFileName, - }); - }) - .catch((err) => reject(err)); - }), - ); - } - return promises; - }; -} +import { getUploadHandler } from "./file-upload-extention"; export const getExtensions = (placeholder, url) => () => [ new DocExtension({}), @@ -143,5 +74,4 @@ export const getExtensions = (placeholder, url) => () => [ new OrderedListExtension(), new TaskListExtension(), new ShortcutsExtension(), - // ...wysiwygPresetArrayWithoutImageExtension, ]; diff --git a/packages/text-editor/src/file-upload-extention.ts b/packages/text-editor/src/file-upload-extention.ts new file mode 100644 index 000000000..a48a05b86 --- /dev/null +++ b/packages/text-editor/src/file-upload-extention.ts @@ -0,0 +1,72 @@ +import { DelayedPromiseCreator, ErrorConstant, invariant } from "remirror"; +import { FetchBuilder } from "@courselit/utils"; +import { Media } from "@courselit/common-models"; +import { ImageAttributes } from "remirror/extensions"; + +type SetProgress = (progress: number) => void; + +interface FileWithProgress { + file: File; + progress: SetProgress; +} + +async function getPresignedUrl(url: string) { + const fetch = new FetchBuilder() + .setUrl(`${url}/api/media/presigned`) + .setIsGraphQLEndpoint(false) + .build(); + return await fetch.exec(); +} + +export function getUploadHandler(url: string) { + return function uploadFileToMediaLit( + files: FileWithProgress[], + ): DelayedPromiseCreator[] { + invariant(files.length > 0, { + code: ErrorConstant.EXTENSION, + message: + "The upload handler was applied for the image extension without any valid files", + }); + + let completed = 0; + const promises: DelayedPromiseCreator[] = []; + + for (const { file, progress } of files) { + promises.push( + () => + new Promise((resolve, reject) => { + if (file.size > 2097152) { + // 2 MB (taken from: https://stackoverflow.com/a/49490014) + return reject("File is larger than 2MB"); + } + getPresignedUrl(url) + .then(({ signature, endpoint }) => { + const fD = new FormData(); + fD.append("caption", file.name); + fD.append("access", "public"); + fD.append("file", file); + + return fetch(`${endpoint}/media/create`, { + method: "POST", + headers: { + "x-medialit-signature": signature, + }, + body: fD, + }); + }) + .then((data) => data.json()) + .then((data: Media) => { + completed += 1; + progress(completed / files.length); + resolve({ + src: data.file, + fileName: data.originalFileName, + }); + }) + .catch((err) => reject(err.message)); + }), + ); + } + return promises; + }; +} From baef5134810807aa0606600a90efe1235bb19c25 Mon Sep 17 00:00:00 2001 From: Rajat Saxena Date: Mon, 3 Nov 2025 23:25:43 +0530 Subject: [PATCH 3/4] Ported community posts and text editor uploads to new MediaLit API --- apps/web/app/api/media/presigned/route.ts | 12 +- .../community/create-post-dialog.tsx | 162 +++++++++++----- apps/web/components/community/index.tsx | 167 +++------------- apps/web/components/ui/progress.tsx | 28 +++ apps/web/package.json | 1 + packages/common-models/src/community-media.ts | 12 +- packages/common-models/src/index.ts | 2 +- .../src/hooks/use-medialit.ts | 131 +++++++++++++ packages/components-library/src/index.ts | 2 +- .../src/media-selector/file-upload-dialog.tsx | 182 +++++------------- .../src/media-selector/index.tsx | 80 +------- pnpm-lock.yaml | 3 + 12 files changed, 381 insertions(+), 401 deletions(-) create mode 100644 apps/web/components/ui/progress.tsx create mode 100644 packages/components-library/src/hooks/use-medialit.ts diff --git a/apps/web/app/api/media/presigned/route.ts b/apps/web/app/api/media/presigned/route.ts index 425c4b61b..844604aa3 100644 --- a/apps/web/app/api/media/presigned/route.ts +++ b/apps/web/app/api/media/presigned/route.ts @@ -10,8 +10,8 @@ import { MediaLit } from "medialit"; const medialit = new MediaLit({ apiKey: process.env.MEDIALIT_APIKEY, - endpoint: process.env.MEDIALIT_SERVER -}) + endpoint: process.env.MEDIALIT_SERVER, +}); export async function POST(req: NextRequest) { const domain = await DomainModel.findOne({ @@ -47,11 +47,11 @@ export async function POST(req: NextRequest) { try { let signature = await medialit.getSignature({ - group: domain.name - }) - return Response.json({ + group: domain.name, + }); + return Response.json({ signature, - endpoint: medialit.endpoint + endpoint: medialit.endpoint, }); } catch (err: any) { error(err.message, { diff --git a/apps/web/components/community/create-post-dialog.tsx b/apps/web/components/community/create-post-dialog.tsx index cffa9cf5b..b231fadcb 100644 --- a/apps/web/components/community/create-post-dialog.tsx +++ b/apps/web/components/community/create-post-dialog.tsx @@ -3,7 +3,6 @@ import { useState, useEffect, useContext } from "react"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; -import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { @@ -22,22 +21,41 @@ import { Paperclip, Video, Smile, Image } from "lucide-react"; import { EmojiPicker } from "./emoji-picker"; import { GifSelector } from "./gif-selector"; import { MediaPreview } from "./media-preview"; -import { CommunityPost } from "@courselit/common-models"; +import { CommunityMediaTypes, CommunityPost } from "@courselit/common-models"; import { type MediaItem } from "./media-item"; import { ProfileContext } from "@components/contexts"; +import { + AlertDialog, + AlertDialogContent, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@components/ui/alert-dialog"; +import { + AlertDialogAction, + AlertDialogCancel, +} from "@radix-ui/react-alert-dialog"; +import { Progress } from "@/components/ui/progress"; interface CreatePostDialogProps { - onPostCreated: ( + createPost: ( post: Pick & { media: MediaItem[]; }, ) => void; categories: string[]; + isFileUploading: boolean; + fileUploadProgress: number; + fileBeingUploadedNumber: number; } -export function CreatePostDialog({ - onPostCreated, +export default function CreatePostDialog({ + createPost, categories, + isFileUploading, + fileUploadProgress, + fileBeingUploadedNumber = 0, }: CreatePostDialogProps) { const [isOpen, setIsOpen] = useState(false); const [title, setTitle] = useState(""); @@ -53,10 +71,13 @@ export function CreatePostDialog({ }>({}); const [isPostButtonDisabled, setIsPostButtonDisabled] = useState(true); const { profile } = useContext(ProfileContext); + const [isPosting, setIsPosting] = useState(false); useEffect(() => { - setIsPostButtonDisabled(title.trim() === "" || content.trim() === ""); - }, [title, content]); + setIsPostButtonDisabled( + title.trim() === "" || content.trim() === "" || isPosting, + ); + }, [title, content, isPosting]); const handleEmojiSelect = (emoji: string) => { setContent((prevContent) => prevContent + emoji); @@ -98,9 +119,9 @@ export function CreatePostDialog({ } }; - const handleLinkAdd = (url: string) => { - setContent((prevContent) => `${prevContent} ${url} `); - }; + // const handleLinkAdd = (url: string) => { + // setContent((prevContent) => `${prevContent} ${url} `); + // }; const handleVideoAdd = (url: string) => { if (url.includes("youtube.com") || url.includes("youtu.be")) { @@ -127,7 +148,7 @@ export function CreatePostDialog({ setMedia((prevMedia) => prevMedia.filter((_, i) => i !== index)); }; - const handlePost = () => { + const handlePost = async () => { if (title.trim() === "" || content.trim() === "") { setErrors({ title: title.trim() === "" ? "Title is required" : undefined, @@ -145,14 +166,20 @@ export function CreatePostDialog({ return; } - onPostCreated({ + setIsPosting(true); + await createPost({ category, title, content, media, }); + setIsPosting(false); + + resetForm(); + }; + + const resetForm = () => { setIsOpen(false); - // Reset form setTitle(""); setContent(""); setCategory(""); @@ -160,38 +187,59 @@ export function CreatePostDialog({ setErrors({}); }; + const getUploadableMediaCount = () => { + return media.filter((x) => + [ + CommunityMediaTypes.IMAGE, + CommunityMediaTypes.VIDEO, + CommunityMediaTypes.PDF, + ].includes(x.type as any), + ).length; + }; + + if (!profile) { + return null; + } + return ( - - + + - - -
- - - - {(profile.name - ? profile.name.charAt(0) - : profile.email.charAt(0) - ).toUpperCase()} - - -
- {profile.name} -
-
+ + + + +
+ + + + {(profile.name + ? profile.name.charAt(0) + : profile.email!.charAt(0) + ).toUpperCase()} + + +
+ + {profile.name} + +
+
+
+
@@ -199,7 +247,6 @@ export function CreatePostDialog({ placeholder="Title" value={title} onChange={(e) => setTitle(e.target.value)} - className="text-lg border-none px-0 font-semibold" /> {errors.title && (

@@ -409,7 +456,7 @@ export function CreatePostDialog({ )}

-
+ {/*
-
+
*/}
- - + {isPosting && getUploadableMediaCount() > 0 && ( + <> +

+ Uploading {fileBeingUploadedNumber} of{" "} + {getUploadableMediaCount()} files -{" "} + {Math.round(fileUploadProgress)}% +

+ + + )} + + + + + + + + + + ); } diff --git a/apps/web/components/community/index.tsx b/apps/web/components/community/index.tsx index e3bd2f41f..7c9622e8b 100644 --- a/apps/web/components/community/index.tsx +++ b/apps/web/components/community/index.tsx @@ -1,7 +1,6 @@ "use client"; import { useState, useEffect, useRef, useContext, useCallback } from "react"; -import { CreatePostDialog } from "./create-post-dialog"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { @@ -30,7 +29,11 @@ import { Comment as CommentType } from "./mock-data"; import { useRouter } from "next/navigation"; import { capitalize, FetchBuilder, truncate } from "@courselit/utils"; import { AddressContext, ProfileContext } from "@components/contexts"; -import { PaginatedTable, useToast } from "@courselit/components-library"; +import { + PaginatedTable, + useToast, + useMediaLit, +} from "@courselit/components-library"; import { CommunityMedia, CommunityPost, @@ -57,6 +60,9 @@ import NotFound from "@components/admin/not-found"; import { CommunityInfo } from "./info"; import Banner from "./banner"; import { Textarea } from "@/components/ui/textarea"; +import dynamic from "next/dynamic"; + +const CreatePostDialog = dynamic(() => import("./create-post-dialog")); const itemsPerPage = 10; @@ -70,9 +76,6 @@ export function CommunityForum({ const router = useRouter(); const [showAllCategories, setShowAllCategories] = useState(false); const [posts, setPosts] = useState([]); - const [newComments, setNewComments] = useState<{ - [postId: string]: string; - }>({}); const commentsEndRef = useRef(null); const address = useContext(AddressContext); const { toast } = useToast(); @@ -83,7 +86,6 @@ export function CommunityForum({ null, ); const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false); - const [comments, setComments] = useState([]); const { community, loaded, setCommunity } = useCommunity(id); const { membership, setMembership } = useMembership(id); const { profile } = useContext(ProfileContext); @@ -93,6 +95,12 @@ export function CommunityForum({ null, ); const [refreshCommunityStatus, setRefreshCommunityStatus] = useState(0); + const { isUploading, uploadProgress, uploadFile, cancelUpload } = + useMediaLit({ + signatureEndpoint: `${address.backend}/api/media/presigned`, + access: "public", + }); + const [fileBeingUploadedNumber, setFileBeingUploadedNumber] = useState(0); useEffect(() => { if (membership) { @@ -329,19 +337,6 @@ export function CommunityForum({ } }; - const handleCommentLike = (postId: number, commentId: number) => { - setPosts((prevPosts) => - prevPosts.map((post) => - post.postId === postId - ? { - ...post, - comments: likeComment(post.comments, commentId), - } - : post, - ), - ); - }; - const likeComment = ( comments: CommentType[], commentId: number, @@ -362,27 +357,6 @@ export function CommunityForum({ ); }; - const handleCommentReply = ( - postId: number, - parentCommentId: number, - content: string, - ) => { - setPosts((prevPosts) => - prevPosts.map((post) => - post.postId === postId - ? { - ...post, - comments: addReplyToComment( - post.comments, - parentCommentId, - content, - ), - } - : post, - ), - ); - }; - const addReplyToComment = ( comments: CommentType[], parentCommentId: number, @@ -417,64 +391,6 @@ export function CommunityForum({ ); }; - const handleNewCommentChange = (postId: string, content: string) => { - setNewComments((prev) => ({ ...prev, [postId]: content })); - }; - - const handlePostComment = (postId: string) => { - const content = newComments[postId]; - if (content && content.trim()) { - setPosts((prevPosts) => - prevPosts.map((post) => - post.postId === postId - ? { - ...post, - comments: [ - ...comments, - { - id: Date.now(), - author: "Current User", - avatar: "/placeholder.svg", - content: content.trim(), - likes: 0, - hasLiked: false, - time: "Just now", - replies: [], - }, - ], - } - : post, - ), - ); - setNewComments((prev) => ({ ...prev, [postId]: "" })); - } - }; - - const getPresignedUrl = async () => { - const fetch = new FetchBuilder() - .setUrl(`${address.backend}/api/media/presigned`) - .setIsGraphQLEndpoint(false) - .build(); - const response = await fetch.exec(); - return response.url; - }; - - // const removeFile = async (mediaId: string) => { - // try { - // const fetch = new FetchBuilder() - // .setUrl(`${address.backend}/api/media/${mediaId}`) - // .setHttpMethod("DELETE") - // .setIsGraphQLEndpoint(false) - // .build(); - // const response = await fetch.exec(); - // if (response.message !== "success") { - // throw new Error(response.message); - // } - // } catch (err: any) { - // console.error("Error in removing file", err.message); - // } - // }; - const createPost = async ( newPost: Pick & { media: MediaItem[]; @@ -561,13 +477,20 @@ export function CommunityForum({ description: err.message, variant: "destructive", }); + } finally { + setFileBeingUploadedNumber(0); } }; const uploadAttachments = async (media: MediaItem[]) => { - for (const m of media) { + for (const i in media) { + const m = media[i]; if (m.file) { - const uploadedMedia = await uploadFile(m.file); + setFileBeingUploadedNumber(+i + 1); + // TODO: Add file size limit + const uploadedMedia = (await uploadFile( + m.file, + )) as unknown as Media; m.media = uploadedMedia; m.file = undefined; m.url = undefined; @@ -576,41 +499,6 @@ export function CommunityForum({ return media; }; - const uploadFile = async (file: File) => { - try { - const presignedUrl = await getPresignedUrl(); - const media = await uploadToServer(presignedUrl, file); - return media; - } catch (err) { - throw new Error(`Media upload: ${err.message}`); - } - }; - - const uploadToServer = async ( - presignedUrl: string, - file: File, - ): Promise => { - const fD = new FormData(); - fD.append("caption", file.name); - fD.append("access", "public"); - fD.append("file", file); - - const res = await fetch(presignedUrl, { - method: "POST", - body: fD, - }); - if (res.status === 200) { - const media = await res.json(); - if (media) { - delete media.group; - } - return media; - } else { - const resp = await res.json(); - throw new Error(resp.error); - } - }; - const renderMediaPreview = ( media: CommunityMedia, options?: { @@ -916,7 +804,7 @@ export function CommunityForum({ } }; - if (!loaded) { + if (!loaded || !profile) { return ; } @@ -1048,7 +936,12 @@ export function CommunityForum({ categories={categories.filter( (x) => x !== "All", )} - onPostCreated={createPost} + createPost={createPost} + isFileUploading={isUploading} + fileUploadProgress={uploadProgress} + fileBeingUploadedNumber={ + fileBeingUploadedNumber + } /> ) : null ) : ( diff --git a/apps/web/components/ui/progress.tsx b/apps/web/components/ui/progress.tsx new file mode 100644 index 000000000..b27555e02 --- /dev/null +++ b/apps/web/components/ui/progress.tsx @@ -0,0 +1,28 @@ +"use client"; + +import * as React from "react"; +import * as ProgressPrimitive from "@radix-ui/react-progress"; + +import { cn } from "@/lib/shadcn-utils"; + +const Progress = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, value, ...props }, ref) => ( + + + +)); +Progress.displayName = ProgressPrimitive.Root.displayName; + +export { Progress }; diff --git a/apps/web/package.json b/apps/web/package.json index 3672ebdf1..6ca485dd2 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -29,6 +29,7 @@ "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-label": "^2.1.2", "@radix-ui/react-popover": "^1.1.6", + "@radix-ui/react-progress": "^1.1.7", "@radix-ui/react-radio-group": "^1.2.3", "@radix-ui/react-scroll-area": "^1.2.3", "@radix-ui/react-select": "^2.1.6", diff --git a/packages/common-models/src/community-media.ts b/packages/common-models/src/community-media.ts index a627f1e5f..cc7c12b3f 100644 --- a/packages/common-models/src/community-media.ts +++ b/packages/common-models/src/community-media.ts @@ -1,7 +1,17 @@ import { Media } from "./media"; +export const CommunityMediaTypes = { + YOUTUBE: "youtube", + PDF: "pdf", + IMAGE: "image", + VIDEO: "video", + GIF: "gif", +} as const; +export type CommunityMediaType = + (typeof CommunityMediaTypes)[keyof typeof CommunityMediaTypes]; + export interface CommunityMedia { - type: "youtube" | "pdf" | "image" | "video" | "gif"; + type: CommunityMediaType; title: string; url?: string; media?: Media; diff --git a/packages/common-models/src/index.ts b/packages/common-models/src/index.ts index 1bad06f51..ff62e5839 100644 --- a/packages/common-models/src/index.ts +++ b/packages/common-models/src/index.ts @@ -57,7 +57,7 @@ export type { ServerConfig } from "./server-config"; export type { Community } from "./community"; export type { CommunityPost } from "./community-post"; export type { CommunityMemberStatus } from "./community-member-status"; -export type { CommunityMedia } from "./community-media"; +export * from "./community-media"; export type { CommunityComment } from "./community-comment"; export type { CommunityCommentReply } from "./community-comment-reply"; export type { PaymentPlanType, PaymentPlan } from "./payment-plan"; diff --git a/packages/components-library/src/hooks/use-medialit.ts b/packages/components-library/src/hooks/use-medialit.ts new file mode 100644 index 000000000..facc27eb9 --- /dev/null +++ b/packages/components-library/src/hooks/use-medialit.ts @@ -0,0 +1,131 @@ +import { useState, useRef, useEffect } from "react"; +import { Upload as TUSUpload, UploadOptions } from "tus-js-client"; + +interface UseMediaLitProps { + signatureEndpoint: string; + access: any; + chunkSize?: number; + onUploadComplete?: (media: Record) => void; + onUploadError?: (error: Error) => void; +} + +export function useMediaLit({ + signatureEndpoint, + access, + chunkSize, + onUploadComplete, + onUploadError, +}: UseMediaLitProps) { + const [isUploading, setIsUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(0); + const [file, setFile] = useState(null); + const uploadRef = useRef(null); + + const getSignature = async (): Promise<{ + signature?: string; + endpoint?: string; + }> => { + const res = await fetch(signatureEndpoint, { method: "POST" }); + if (!res.ok) return {}; + return res.json(); + }; + + const uploadFile = ( + fileToUpload: File, + metadata: Record = {}, + ): Promise> => { + setFile(fileToUpload); + setIsUploading(true); + setUploadProgress(0); + + return new Promise>((resolve, reject) => { + getSignature() + .then(({ signature, endpoint }) => { + if (!signature || !endpoint) { + const err = new Error("Failed to obtain signature"); + setIsUploading(false); + onUploadError?.(err); + return reject(err); + } + + const uploadUrl = `${endpoint}/media/create/resumable`; + + const tusOptions: UploadOptions = { + endpoint: uploadUrl, + removeFingerprintOnSuccess: true, + retryDelays: [0, 3000, 5000], + headers: { + "x-medialit-signature": signature, + }, + metadata: { + fileName: fileToUpload.name, + mimeType: fileToUpload.type, + access, + ...metadata, + }, + onProgress: (bytesUploaded, bytesTotal) => { + const percentage = + (bytesUploaded / bytesTotal) * 100; + setUploadProgress(percentage); + }, + onError: (error) => { + setIsUploading(false); + onUploadError?.(error); + reject(error); + }, + onSuccess: (payload) => { + const mediaString = + payload.lastResponse.getHeader("Media"); + const media: Record = mediaString + ? JSON.parse(mediaString) + : null; + if (media) { + onUploadComplete?.(media); + resolve(media); + } + setIsUploading(false); + setUploadProgress(100); + setFile(null); + }, + }; + if (chunkSize) { + tusOptions.chunkSize = chunkSize; + } + + const upload = new TUSUpload(fileToUpload, tusOptions); + uploadRef.current = upload; + + upload.findPreviousUploads().then((previous) => { + if (previous.length) { + upload.resumeFromPreviousUpload(previous[0]); + } + + upload.start(); + }); + }) + .catch((err) => { + setIsUploading(false); + onUploadError?.(err); + reject(err); + }); + }); + }; + + const abortUpload = () => { + if (uploadRef.current) { + uploadRef.current.abort(); + uploadRef.current = null; + } + setIsUploading(false); + }; + + useEffect(() => abortUpload, []); + + return { + file, + isUploading, + uploadProgress, + uploadFile, + cancelUpload: abortUpload, + }; +} diff --git a/packages/components-library/src/index.ts b/packages/components-library/src/index.ts index 14b80cd50..6b1dd53b6 100644 --- a/packages/components-library/src/index.ts +++ b/packages/components-library/src/index.ts @@ -62,11 +62,11 @@ export * from "./vertical-padding-selector"; export * from "./max-width-selector"; export * from "./lib/utils"; export * from "./section-background-panel"; +export * from "./hooks/use-medialit"; export { PriceTag, Section, - // WidgetHelpers, CourseItem, Select, Link, diff --git a/packages/components-library/src/media-selector/file-upload-dialog.tsx b/packages/components-library/src/media-selector/file-upload-dialog.tsx index 1dc9edd37..736ca4506 100644 --- a/packages/components-library/src/media-selector/file-upload-dialog.tsx +++ b/packages/components-library/src/media-selector/file-upload-dialog.tsx @@ -1,6 +1,4 @@ -import type React from "react"; - -import { useState, useRef } from "react"; +import React, { useState, useRef } from "react"; import { AlertDialog, AlertDialogCancel, @@ -15,13 +13,12 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Progress } from "@/components/ui/progress"; import { Upload } from "lucide-react"; -import { FetchBuilder } from "@courselit/utils"; import { Address, Media } from "@courselit/common-models"; import { useToast } from "@/hooks/use-toast"; import Access from "./access"; -import { Upload as TUSUpload } from "tus-js-client"; import MediaType from "./type"; import { AlertDialogAction } from "@radix-ui/react-alert-dialog"; +import { useMediaLit } from "@/hooks/use-medialit"; interface FileUploadAlertDialogProps { acceptedMimeTypes?: string[]; @@ -47,22 +44,42 @@ export function FileUploadAlertDialog({ const [file, setFile] = useState(null); const [caption, setCaption] = useState(""); const [isDragging, setIsDragging] = useState(false); - const [isUploading, setIsUploading] = useState(false); - const [uploadProgress, setUploadProgress] = useState(0); const [fileError, setFileError] = useState(""); const fileInputRef = useRef(null); const { toast } = useToast(); - const uploadRef = useRef(null); + const { isUploading, uploadProgress, uploadFile, cancelUpload } = + useMediaLit({ + signatureEndpoint: `${address.backend}/api/media/presigned`, + access, + onUploadComplete: (media) => { + onSuccess(media as unknown as Media); + resetState(); + setOpen(false); + }, + onUploadError: (error) => { + toast({ + title: "Upload Failed", + description: error.message, + variant: "destructive", + }); + }, + }); - const isValidMimeType = (mimeType: string): boolean => { - if (acceptedMimeTypes.length === 0) return true; - return acceptedMimeTypes.includes(mimeType); + const resetState = () => { + setFile(null); + setCaption(""); + setFileError(""); + setIsDragging(false); + setOpen(false); }; + const isValidMimeType = (mimeType: string) => + acceptedMimeTypes.length === 0 || acceptedMimeTypes.includes(mimeType); + const handleFileValidation = (selectedFile: File) => { if (!isValidMimeType(selectedFile.type)) { setFileError( - `Invalid file type. Accepted types: ${acceptedMimeTypes.join(", ")}`, + `Invalid file type. Accepted: ${acceptedMimeTypes.join(", ")}`, ); setFile(null); return; @@ -75,125 +92,24 @@ export function FileUploadAlertDialog({ e.preventDefault(); setIsDragging(true); }; - - const handleDragLeave = () => { - setIsDragging(false); - }; - + const handleDragLeave = () => setIsDragging(false); const handleDrop = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); const droppedFiles = e.dataTransfer.files; - if (droppedFiles.length > 0) { - handleFileValidation(droppedFiles[0]); - } + if (droppedFiles.length > 0) handleFileValidation(droppedFiles[0]); }; const handleFileChange = (e: React.ChangeEvent) => { - if (e.target.files && e.target.files.length > 0) { - handleFileValidation(e.target.files[0]); - } + if (e.target.files?.length) handleFileValidation(e.target.files[0]); }; const handleUpload = async () => { - if (file) { - setIsUploading(true); - setUploadProgress(0); - - try { - const { signature, endpoint } = await getSignature(); - - if (!signature || !endpoint) { - toast({ - title: "Error", - description: "Failed to get signature", - variant: "destructive", - }); - } - const uploadUrl = `${endpoint}/media/create/resumable`; - const metadata = { - fileName: file.name, - mimeType: file.type, - access, - caption: caption || "", - }; - const upload = new TUSUpload(file, { - endpoint: uploadUrl, - // chunkSize: 1024000, // 10 MB - removeFingerprintOnSuccess: true, - retryDelays: [0, 3000, 5000], - headers: { - "x-medialit-signature": signature, - }, - metadata, - onProgress: (bytesUploaded, bytesTotal) => { - const percentage = (bytesUploaded / bytesTotal) * 100; - setUploadProgress(percentage); - }, - onError: (error) => { - toast({ - title: "Error", - description: error.message, - variant: "destructive", - }); - setIsUploading(false); - }, - onSuccess: async (payload) => { - const mediaString = - payload.lastResponse.getHeader("Media"); - const media: Media = mediaString - ? JSON.parse(mediaString) - : null; - if (media) { - media && onSuccess(media); - - setOpen(false); - setFile(null); - setCaption(""); - setUploadProgress(0); - setIsUploading(false); - } - }, - }); - uploadRef.current = upload; - - upload.findPreviousUploads().then(function (previousUploads) { - if (previousUploads.length) { - upload.resumeFromPreviousUpload(previousUploads[0]); - } - - upload.start(); - }); - } catch (error) { - toast({ - title: "Error", - description: error.message, - variant: "destructive", - }); - setIsUploading(false); - } - } - }; - - const handleReset = () => { - if (uploadRef.current) { - uploadRef.current.abort(); - uploadRef.current = null; - } - setFile(null); - setCaption(""); - setUploadProgress(0); - setFileError(""); - setOpen(false); - setIsUploading(false); - }; - - const getSignature = async () => { - const fetch = new FetchBuilder() - .setUrl(`${address.backend}/api/media/presigned`) - .setIsGraphQLEndpoint(false) - .build(); - return await fetch.exec(); + if (!file) return; + await uploadFile(file, { + caption: caption || "", + type, + }); }; const acceptAttribute = @@ -211,6 +127,7 @@ export function FileUploadAlertDialog({ Upload file + Upload File @@ -229,7 +146,11 @@ export function FileUploadAlertDialog({ isDragging ? "border-primary bg-primary/5" : "border-muted-foreground/25 hover:border-muted-foreground/50" - } ${file ? "bg-primary/5" : ""} ${fileError ? "border-destructive bg-destructive/5" : ""}`} + } ${file ? "bg-primary/5" : ""} ${ + fileError + ? "border-destructive bg-destructive/5" + : "" + }`} style={{ pointerEvents: isUploading ? "none" : "auto" }} > @@ -262,7 +182,9 @@ export function FileUploadAlertDialog({ {file ? file.name : "Drop file here or click"}

{file && !fileError && ( -

{`Selected: ${(file.size / 1024).toFixed(2)} KB`}

+

+ Selected: {(file.size / 1024).toFixed(2)} KB +

)} {fileError && (

@@ -303,17 +225,11 @@ export function FileUploadAlertDialog({ )} + {isUploading ? ( { - if (uploadRef.current) { - uploadRef.current.abort(); - uploadRef.current = null; - } - setIsUploading(false); - // setUploadProgress(0); - }} + onClick={cancelUpload} disabled={Math.round(uploadProgress) > 99} > {Math.round(uploadProgress) > 99 @@ -322,7 +238,7 @@ export function FileUploadAlertDialog({ ) : ( <> - + Cancel diff --git a/packages/components-library/src/media-selector/index.tsx b/packages/components-library/src/media-selector/index.tsx index 0dc5680f4..3326d3293 100644 --- a/packages/components-library/src/media-selector/index.tsx +++ b/packages/components-library/src/media-selector/index.tsx @@ -1,11 +1,10 @@ "use client"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { Image } from "../image"; import { Address, Media, Profile } from "@courselit/common-models"; import Access from "./access"; import { FetchBuilder } from "@courselit/utils"; -import React from "react"; import { Button2, PageBuilderPropertyHeader, Tooltip, useToast } from ".."; import { X } from "lucide-react"; import { FileUploadAlertDialog } from "./file-upload-dialog"; @@ -61,17 +60,7 @@ interface MediaSelectorProps { const MediaSelector = (props: MediaSelectorProps) => { const [dialogOpened, setDialogOpened] = useState(false); - const [error, setError] = useState(""); const [uploading, setUploading] = useState(false); - const defaultUploadData = { - caption: "", - uploading: false, - public: props.access === "public", - }; - const [uploadData, setUploadData] = useState(defaultUploadData); - const fileInput: React.RefObject = React.createRef(); - const [selectedFile, setSelectedFile] = useState(); - const [caption, setCaption] = useState(""); const { toast } = useToast(); const { strings, @@ -96,73 +85,6 @@ const MediaSelector = (props: MediaSelectorProps) => { props.onSelection(media); }; - const getPresignedUrl = async () => { - const fetch = new FetchBuilder() - .setUrl(`${address.backend}/api/media/presigned`) - .setIsGraphQLEndpoint(false) - .build(); - const { endpoint, signature } = await fetch.exec(); - return `${endpoint}/media/create?signature=${signature}`; - }; - - useEffect(() => { - if (!dialogOpened) { - setSelectedFile(undefined); - setCaption(""); - } - }, [dialogOpened]); - - const uploadToServer = async (presignedUrl: string): Promise => { - const fD = new FormData(); - fD.append("caption", (uploadData.caption = caption)); - fD.append("access", uploadData.public ? "public" : "private"); - fD.append("file", selectedFile); - - setUploadData( - Object.assign({}, uploadData, { - uploading: true, - }), - ); - const res = await fetch(presignedUrl, { - method: "POST", - body: fD, - }); - if (res.status === 200) { - const media = await res.json(); - if (media) { - delete media.group; - } - return media; - } else { - const resp = await res.json(); - throw new Error(resp.error); - } - }; - - const uploadFile = async (e: React.FormEvent) => { - e.preventDefault(); - const file = selectedFile; - - if (!file) { - setError("File is required"); - return; - } - - try { - setUploading(true); - const presignedUrl = await getPresignedUrl(); - const media = await uploadToServer(presignedUrl); - onSelection(media); - } catch (err: any) { - onError(err); - } finally { - setUploading(false); - setSelectedFile(undefined); - setCaption(""); - setDialogOpened(false); - } - }; - const removeFile = async () => { try { setUploading(true); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8cc11cb11..aa30adacc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -245,6 +245,9 @@ importers: '@radix-ui/react-popover': specifier: ^1.1.6 version: 1.1.14(@types/react-dom@18.3.6(@types/react@18.3.20))(@types/react@18.3.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-progress': + specifier: ^1.1.7 + version: 1.1.7(@types/react-dom@18.3.6(@types/react@18.3.20))(@types/react@18.3.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-radio-group': specifier: ^1.2.3 version: 1.3.4(@types/react-dom@18.3.6(@types/react@18.3.20))(@types/react@18.3.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) From 0e9de38b5cb9ba7ec4fa511b698fe9ee5617e6a9 Mon Sep 17 00:00:00 2001 From: Rajat Saxena Date: Tue, 4 Nov 2025 00:10:13 +0530 Subject: [PATCH 4/4] CodeQL fixes --- apps/web/components/community/index.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/web/components/community/index.tsx b/apps/web/components/community/index.tsx index 7c9622e8b..38d05ba20 100644 --- a/apps/web/components/community/index.tsx +++ b/apps/web/components/community/index.tsx @@ -95,11 +95,10 @@ export function CommunityForum({ null, ); const [refreshCommunityStatus, setRefreshCommunityStatus] = useState(0); - const { isUploading, uploadProgress, uploadFile, cancelUpload } = - useMediaLit({ - signatureEndpoint: `${address.backend}/api/media/presigned`, - access: "public", - }); + const { isUploading, uploadProgress, uploadFile } = useMediaLit({ + signatureEndpoint: `${address.backend}/api/media/presigned`, + access: "public", + }); const [fileBeingUploadedNumber, setFileBeingUploadedNumber] = useState(0); useEffect(() => {