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 @@
+
\ 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 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}
}
-
-
+ 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 (
-
+ {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(() => {