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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<your_region>.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
Expand Down
189 changes: 160 additions & 29 deletions app/components/comment-composer.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof loader>();
const { create } = useComments(taskId);
const inputRef = React.useRef<HTMLTextAreaElement>(null);
const fileInputRef = React.useRef<HTMLInputElement>(null);
const [files, setFiles] = React.useState<File[]>([]);
const [isUploading, setIsUploading] = React.useState(false);

const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
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;
Expand All @@ -49,7 +43,114 @@ export function CommentComposer({ taskId }: Props) {
handleResize.current();
}, []);

const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
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<HTMLInputElement>) {
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<HTMLTextAreaElement>) {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
e.currentTarget.form?.requestSubmit();
Expand All @@ -72,11 +173,16 @@ export function CommentComposer({ taskId }: Props) {
e.preventDefault();
return;
}
};
}

return (
<form onSubmit={handleSubmit} className="relative">
{create.isPending && (
<form
onSubmit={handleSubmit}
className="relative"
onDrop={!isFileUploadEnabled ? undefined : handleDrop}
onDragOver={!isFileUploadEnabled ? undefined : handleDragOver}
>
{isProcessing && (
<div className="absolute top-2 right-2 i-svg-spinners-270-ring text-secondary" />
)}

Expand All @@ -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}
Expand All @@ -96,8 +202,21 @@ export function CommentComposer({ taskId }: Props) {

<div className="text-sm text-secondary flex justify-between gap-4">
<div className="flex items-center gap-2">
<div className="i-solar-link-round-angle-bold-duotone" /> Drop files
here
<FileInput
multiple
accept="image/png,image/jpeg,image/gif,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
onChange={handleFilesSelect}
disabled={!isFileUploadEnabled || isProcessing || files.length >= 3}
ref={fileInputRef}
>
<div className="i-solar-link-round-angle-bold-duotone" />{" "}
{isUploading && files.length ? "Uploading..." : "Drop files here"}{" "}
{files.length > 0 && !isUploading && (
<span className="animate-fade-in animate-duration-150">
( 3 files max. 5MB limit per file. )
</span>
)}
</FileInput>
</div>

<button
Expand All @@ -107,6 +226,18 @@ export function CommentComposer({ taskId }: Props) {
<div className="i-solar-command-linear" /> + Enter to send
</button>
</div>

{isFileUploadEnabled && files.length > 0 && (
<div className="flex gap-2 mt-2">
{files.map((file, i) => (
<FileSelectItem
key={`${file.name}-${i}`}
file={file}
onRemove={() => !isProcessing && removeFile(i)}
/>
))}
</div>
)}
</form>
);
}
55 changes: 49 additions & 6 deletions app/components/content.tsx
Original file line number Diff line number Diff line change
@@ -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<Media | undefined>(undefined);
const ref = React.useRef<HTMLDivElement>(null);

React.useEffect(() => {
Expand Down Expand Up @@ -43,12 +60,38 @@ function Content({ content, onCheckListItem, isDisabled = false }: Props) {
};
}, [onCheckListItem, isDisabled]);

if (!content) return null;
if (!comment) return null;

return (
<article className="comment-article" ref={ref}>
{parse(content)}
</article>
<>
<article className="comment-article" ref={ref}>
{parse(comment.content)}

{comment.Media && comment.Media.length > 0 && (
<div className="flex flex-col gap-2 mt-2">
{comment.Media.map((media) => (
<div key={media.id}>
<button
type="button"
className="block bg-transparent border-none p-0 focus:outline-none cursor-pointer"
onClick={() => setMedia(media)}
>
<MediaItem media={media} />
</button>
</div>
))}
</div>
)}
</article>

<MediaPreview
comment={comment}
media={media}
open={Boolean(media)}
onClose={() => setMedia(undefined)}
setMedia={setMedia}
/>
</>
);
}

Expand Down
9 changes: 4 additions & 5 deletions app/components/copy-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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">();

Expand All @@ -29,13 +31,10 @@ function CopyButton({
return (
<Button
onClick={handleCopy}
className={clsx(
"w-full text-sm font-medium flex items-center justify-center bg-neutral-700 text-white dark:bg-white dark:text-neutral-900 px-3 !py-1 gap-1",
className,
)}
className={clsx("text-sm font-medium", className)}
disabled={disabled}
>
{copyStatus === "copied" ? "Copied" : "Copy"}{" "}
{children || (copyStatus === "copied" ? "Copied" : "Copy")}{" "}
<div
className={clsx(
copyStatus === "copied"
Expand Down
Loading