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..844604aa3 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/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..38d05ba20 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,11 @@ export function CommunityForum({ null, ); const [refreshCommunityStatus, setRefreshCommunityStatus] = useState(0); + const { isUploading, uploadProgress, uploadFile } = useMediaLit({ + signatureEndpoint: `${address.backend}/api/media/presigned`, + access: "public", + }); + const [fileBeingUploadedNumber, setFileBeingUploadedNumber] = useState(0); useEffect(() => { if (membership) { @@ -329,19 +336,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 +356,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 +390,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 +476,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 +498,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 +803,7 @@ export function CommunityForum({ } }; - if (!loaded) { + if (!loaded || !profile) { return ; } @@ -1048,7 +935,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/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..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", @@ -57,6 +58,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/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/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/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 new file mode 100644 index 000000000..736ca4506 --- /dev/null +++ b/packages/components-library/src/media-selector/file-upload-dialog.tsx @@ -0,0 +1,258 @@ +import React, { 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 { Address, Media } from "@courselit/common-models"; +import { useToast } from "@/hooks/use-toast"; +import Access from "./access"; +import MediaType from "./type"; +import { AlertDialogAction } from "@radix-ui/react-alert-dialog"; +import { useMediaLit } from "@/hooks/use-medialit"; + +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 [fileError, setFileError] = useState(""); + const fileInputRef = useRef(null); + const { toast } = useToast(); + 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 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: ${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?.length) handleFileValidation(e.target.files[0]); + }; + + const handleUpload = async () => { + if (!file) return; + await uploadFile(file, { + caption: caption || "", + type, + }); + }; + + 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 ? ( + 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..3326d3293 100644 --- a/packages/components-library/src/media-selector/index.tsx +++ b/packages/components-library/src/media-selector/index.tsx @@ -1,16 +1,14 @@ "use client"; -import { ChangeEvent, 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 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 +52,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; @@ -69,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,79 +77,14 @@ const MediaSelector = (props: MediaSelectorProps) => { variant: "destructive", }); }, + access, + type, } = props; const onSelection = (media: Media) => { props.onSelection(media); }; - const getPresignedUrl = async () => { - const fetch = new FetchBuilder() - .setUrl(`${address.backend}/api/media/presigned`) - .setIsGraphQLEndpoint(false) - .build(); - const response = await fetch.exec(); - return response.url; - }; - - 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); @@ -228,66 +144,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/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; + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5e622b3b1..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) @@ -329,6 +332,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 +551,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 +575,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 +588,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 +632,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 +3341,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 +5673,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 +5825,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 +7549,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 +7734,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 +7800,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 +7958,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 +8796,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 +9214,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 +9911,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 +13364,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 +17496,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 +17648,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 +19931,8 @@ snapshots: joycon@3.1.1: {} + js-base64@3.7.8: {} + js-sha256@0.10.1: {} js-stringify@1.0.2: {} @@ -20066,6 +20161,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 +20212,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 +20436,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 +21419,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 +22147,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 +23058,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: