From 180a5053a35b641b8afefaf0369752b1eec2931f Mon Sep 17 00:00:00 2001 From: Ebenezer Arthur Date: Thu, 12 Jun 2025 13:30:41 +0000 Subject: [PATCH 1/3] file uploads --- README.md | 8 + app/components/comment-composer.tsx | 189 ++- app/components/content.tsx | 55 +- app/components/copy-button.tsx | 9 +- app/components/edit-comment-input.tsx | 112 +- app/components/file-input.tsx | 20 + app/components/file-select-item.tsx | 54 + app/components/invite-card.tsx | 1 + app/components/media-item.tsx | 69 + app/components/media-preview.tsx | 153 ++ app/components/media-select-item.tsx | 62 + app/components/modal.tsx | 46 + app/components/non-image-thumb.tsx | 83 ++ app/components/profile.tsx | 18 + app/components/task-comment.tsx | 26 +- app/lib/files.ts | 25 + app/lib/is-image.ts | 8 + app/lib/send-discord.ts | 32 +- app/lib/upload-media.ts | 55 + app/lib/use-comments-edit.ts | 22 + app/lib/use-comments.ts | 5 +- app/lib/webhook-types.ts | 1 + app/routes/comments.tsx | 30 +- app/routes/media.tsx | 143 ++ package.json | 4 + .../20250611200156_add_media/migration.sql | 17 + prisma/schema.prisma | 15 + yarn.lock | 1317 ++++++++++++++++- 28 files changed, 2489 insertions(+), 90 deletions(-) create mode 100644 app/components/file-input.tsx create mode 100644 app/components/file-select-item.tsx create mode 100644 app/components/media-item.tsx create mode 100644 app/components/media-preview.tsx create mode 100644 app/components/media-select-item.tsx create mode 100644 app/components/modal.tsx create mode 100644 app/components/non-image-thumb.tsx create mode 100644 app/components/profile.tsx create mode 100644 app/lib/files.ts create mode 100644 app/lib/is-image.ts create mode 100644 app/lib/upload-media.ts create mode 100644 app/routes/media.tsx create mode 100644 prisma/migrations/20250611200156_add_media/migration.sql diff --git a/README.md b/README.md index 997e180..e74981b 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,14 @@ BASE_URL= # falls back to VERCEL_PROJECT_PRODUCTION_URL which is set on Vercel e WEBHOOK_URL= # optional DISCORD_WEBHOOK_URL= # optional, see Webhook section DISCORD_BOT_NAME= # optional, defaults to "kovacs" + +# File Upload Configuration (optional) +VITE_ENABLE_FILE_UPLOAD=true +AWS_ENDPOINT=https://s3..amazonaws.com/ +AWS_REGION=your_region +AWS_ACCESS_KEY=your_aws_access_key +AWS_SECRET_KEY=your_aws_secret_key +AWS_BUCKET_NAME=your_bucket_name ``` ## Webhook Integration diff --git a/app/components/comment-composer.tsx b/app/components/comment-composer.tsx index bb54d6a..44cc2d0 100644 --- a/app/components/comment-composer.tsx +++ b/app/components/comment-composer.tsx @@ -1,40 +1,34 @@ import React from "react"; import { useLoaderData } from "react-router"; import { magicInput } from "~/lib/magic-input"; +import { uploadMedia } from "~/lib/upload-media"; import { useComments } from "~/lib/use-comments"; import type { loader } from "~/routes/_index"; +import { FileInput } from "./file-input"; +import { FileSelectItem } from "./file-select-item"; interface Props { taskId: number; } +export interface Media { + url: string; + filename: string; + size: number; + contentType: string; + thumbnail?: string; +} + export function CommentComposer({ taskId }: Props) { const { user } = useLoaderData(); const { create } = useComments(taskId); const inputRef = React.useRef(null); + const fileInputRef = React.useRef(null); + const [files, setFiles] = React.useState([]); + const [isUploading, setIsUploading] = React.useState(false); - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - const form = e.currentTarget; - const formData = new FormData(form); - const content = formData.get("content") as string; - - if (!content.trim()) return; - - create.mutate( - { taskId, content: content.trim(), authorId: user.id }, - { - onSuccess: () => { - form.reset(); - - if (inputRef.current) { - inputRef.current.style.height = "auto"; - } - setTimeout(() => inputRef.current?.focus(), 100); - }, - }, - ); - }; + const isFileUploadEnabled = + import.meta.env.VITE_ENABLE_FILE_UPLOAD === "true"; const handleResize = React.useRef(() => { const textarea = inputRef.current; @@ -49,7 +43,114 @@ export function CommentComposer({ taskId }: Props) { handleResize.current(); }, []); - const handleKeyDown = (e: React.KeyboardEvent) => { + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const form = e.currentTarget; + const formData = new FormData(form); + const content = formData.get("content") as string; + + if (!content.trim() && files.length === 0) return; + + try { + setIsUploading(true); + + let media: Media[] = []; + + if (files.length > 0) { + const uploads = await Promise.all( + files.map((file) => uploadMedia(file)), + ); + + media = uploads.map((res) => ({ + url: res.url, + filename: res.filename, + size: res.size, + contentType: res.contentType, + thumbnail: res.thumbnail, + })); + } + + create.mutate( + { + taskId, + content: content.trim(), + authorId: user.id, + media, + }, + { + onSuccess: () => { + form.reset(); + setFiles([]); + setIsUploading(false); + + if (inputRef.current) { + inputRef.current.style.height = "auto"; + } + setTimeout(() => inputRef.current?.focus(), 100); + }, + onError: () => { + setIsUploading(false); + }, + }, + ); + } catch (error) { + setIsUploading(false); + } + } + + function handleFilesSelect(e: React.ChangeEvent) { + if (!e.target.files?.length) return; + + const newFiles = Array.from(e.target.files); + + const ATTACHMENT_LIMIT = 5 * 1024 * 1024; // 5MB + const oversizedFiles = newFiles.filter( + (file) => file.size > ATTACHMENT_LIMIT, + ); + + if (oversizedFiles.length > 0) { + alert( + `Some files are too large. Maximum 5MB per file. Oversized files: ${oversizedFiles.map((f) => f.name).join(", ")}`, + ); + return; + } + + setFiles((prev) => [...prev, ...newFiles].slice(0, 3)); + } + + function handleDrop(e: React.DragEvent) { + e.preventDefault(); + const droppedFiles = e.dataTransfer.files; + if (droppedFiles.length > 0) { + const newFiles = Array.from(droppedFiles); + + const ATTACHMENT_LIMIT = 5 * 1024 * 1024; // 5MB + const oversizedFiles = newFiles.filter( + (file) => file.size > ATTACHMENT_LIMIT, + ); + + if (oversizedFiles.length > 0) { + alert( + `Some files are too large. Maximum 5MB per file. Oversized files: ${oversizedFiles.map((f) => f.name).join(", ")}`, + ); + return; + } + + setFiles((prev) => [...prev, ...newFiles].slice(0, 3)); + } + } + + function handleDragOver(e: React.DragEvent) { + e.preventDefault(); + } + + function removeFile(index: number) { + setFiles((prev) => prev.filter((_, i) => i !== index)); + } + + const isProcessing = create.isPending || isUploading; + + function handleKeyDown(e: React.KeyboardEvent) { if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { e.preventDefault(); e.currentTarget.form?.requestSubmit(); @@ -72,11 +173,16 @@ export function CommentComposer({ taskId }: Props) { e.preventDefault(); return; } - }; + } return ( -
- {create.isPending && ( + + {isProcessing && (
)} @@ -87,7 +193,7 @@ export function CommentComposer({ taskId }: Props) { name="content" rows={3} ref={inputRef} - disabled={create.isPending} + disabled={isProcessing} onChange={handleResize.current} onInput={handleResize.current} onKeyDown={handleKeyDown} @@ -96,8 +202,21 @@ export function CommentComposer({ taskId }: Props) {
-
Drop files - here + = 3} + ref={fileInputRef} + > +
{" "} + {isUploading && files.length ? "Uploading..." : "Drop files here"}{" "} + {files.length > 0 && !isUploading && ( + + ( 3 files max. 5MB limit per file. ) + + )} +
+ + {isFileUploadEnabled && files.length > 0 && ( +
+ {files.map((file, i) => ( + !isProcessing && removeFile(i)} + /> + ))} +
+ )} ); } diff --git a/app/components/content.tsx b/app/components/content.tsx index 22c1ebe..0a4bd35 100644 --- a/app/components/content.tsx +++ b/app/components/content.tsx @@ -1,14 +1,31 @@ +import type { Media, Prisma } from "@prisma/client"; import parse from "html-react-parser"; import React from "react"; +import { MediaItem } from "./media-item"; +import { MediaPreview } from "./media-preview"; interface Props { - content: string; + comment: Prisma.CommentGetPayload<{ + include: { + Media: true; + author: { + select: { + username: true; + }; + }; + }; + }>; rawContent: string; onCheckListItem: (line: number, checked: boolean) => void; isDisabled?: boolean; } -function Content({ content, onCheckListItem, isDisabled = false }: Props) { +function Content({ + comment, + onCheckListItem, + isDisabled = false, +}: Props) { + const [media, setMedia] = React.useState(undefined); const ref = React.useRef(null); React.useEffect(() => { @@ -43,12 +60,38 @@ function Content({ content, onCheckListItem, isDisabled = false }: Props) { }; }, [onCheckListItem, isDisabled]); - if (!content) return null; + if (!comment) return null; return ( -
- {parse(content)} -
+ <> +
+ {parse(comment.content)} + + {comment.Media && comment.Media.length > 0 && ( +
+ {comment.Media.map((media) => ( +
+ +
+ ))} +
+ )} +
+ + setMedia(undefined)} + setMedia={setMedia} + /> + ); } diff --git a/app/components/copy-button.tsx b/app/components/copy-button.tsx index 7f18939..76c6e93 100644 --- a/app/components/copy-button.tsx +++ b/app/components/copy-button.tsx @@ -6,10 +6,12 @@ function CopyButton({ text, className, disabled, + children, }: { text: string; className?: string; disabled?: boolean; + children?: React.ReactNode; }) { const [copyStatus, setCopyStatus] = React.useState<"failed" | "copied">(); @@ -29,13 +31,10 @@ function CopyButton({ return (