From 61303969e8b0111093884c04119bf9caf577f62f Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Mon, 5 Jan 2026 18:52:50 -0500 Subject: [PATCH] load previous attachments, video support --- src/components/blog/AddAttachmentSection.tsx | 165 +++++++++++++------ src/lib/s3upload.ts | 16 +- src/server/api/routers/misc.ts | 61 ++++++- 3 files changed, 186 insertions(+), 56 deletions(-) diff --git a/src/components/blog/AddAttachmentSection.tsx b/src/components/blog/AddAttachmentSection.tsx index 44e3db8..49e2d93 100644 --- a/src/components/blog/AddAttachmentSection.tsx +++ b/src/components/blog/AddAttachmentSection.tsx @@ -3,6 +3,7 @@ import Dropzone from "./Dropzone"; import XCircle from "~/components/icons/XCircle"; import AddImageToS3 from "~/lib/s3upload"; import { env } from "~/env/client"; +import { api } from "~/lib/api"; export interface AddAttachmentSectionProps { type: "blog" | "project"; @@ -12,73 +13,85 @@ export interface AddAttachmentSectionProps { } export default function AddAttachmentSection(props: AddAttachmentSectionProps) { - const [images, setImages] = createSignal([]); - const [imageHolder, setImageHolder] = createSignal([]); - const [newImageHolder, setNewImageHolder] = createSignal([]); - const [newImageHolderKeys, setNewImageHolderKeys] = createSignal( - [] - ); + const [files, setFiles] = createSignal([]); + const [s3Files, setS3Files] = createSignal< + Array<{ key: string; size: number; lastModified: string }> + >([]); + const [newFileHolder, setNewFileHolder] = createSignal([]); + const [newFileHolderKeys, setNewFileHolderKeys] = createSignal([]); + const [fileTypes, setFileTypes] = createSignal([]); + const [loading, setLoading] = createSignal(false); createEffect(() => { - if (props.existingAttachments) { - const imgStringArr = props.existingAttachments.split(","); - setImageHolder(imgStringArr); + if (props.postTitle) { + loadAttachments(); } }); + const loadAttachments = async () => { + setLoading(true); + try { + const result = await api.misc.listAttachments.query({ + type: props.type, + title: props.postTitle + }); + setS3Files(result.files); + } catch (err) { + console.error("Failed to load attachments:", err); + } finally { + setLoading(false); + } + }; + const handleImageDrop = async (acceptedFiles: File[]) => { if (props.postTitle) { for (const file of acceptedFiles) { - setImages((prev) => [...prev, file]); + setFiles((prev) => [...prev, file]); try { const key = await AddImageToS3(file, props.postTitle, props.type); if (key) { - setNewImageHolderKeys((prev) => [...prev, key]); + setNewFileHolderKeys((prev) => [...prev, key]); const reader = new FileReader(); reader.onload = () => { const str = reader.result; if (str) { - setNewImageHolder((prev) => [...prev, str as string]); + setNewFileHolder((prev) => [...prev, str as string]); + setFileTypes((prev) => [...prev, file.type]); } }; reader.readAsDataURL(file); + + // Refresh the S3 file list + await loadAttachments(); } } catch (err) { - console.error("Failed to upload image:", err); + console.error("Failed to upload file:", err); } } } }; - const removeImage = async (index: number, key: string) => { - if (props.postId && props.existingAttachments) { - const imgStringArr = props.existingAttachments.split(","); - const newString = imgStringArr.filter((str) => str !== key).join(","); + const removeImage = async (key: string) => { + try { + await fetch("/api/trpc/misc.simpleDeleteImage", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ key }) + }); - try { - await fetch("/api/trpc/misc.deleteImage", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - key, - newAttachmentString: newString, - type: props.type, - id: props.postId - }) - }); - - setImageHolder((prev) => prev.filter((_, i) => i !== index)); - } catch (err) { - console.error("Failed to delete image:", err); - } + // Refresh the S3 file list + await loadAttachments(); + } catch (err) { + console.error("Failed to delete file:", err); } }; const removeNewImage = async (index: number, key: string) => { - setImages((prev) => prev.filter((_, i) => i !== index)); - setNewImageHolder((prev) => prev.filter((_, i) => i !== index)); + setFiles((prev) => prev.filter((_, i) => i !== index)); + setNewFileHolder((prev) => prev.filter((_, i) => i !== index)); + setFileTypes((prev) => prev.filter((_, i) => i !== index)); try { await fetch("/api/trpc/misc.simpleDeleteImage", { @@ -87,7 +100,7 @@ export default function AddAttachmentSection(props: AddAttachmentSectionProps) { body: JSON.stringify({ key }) }); } catch (err) { - console.error("Failed to delete image:", err); + console.error("Failed to delete file:", err); } }; @@ -101,6 +114,15 @@ export default function AddAttachmentSection(props: AddAttachmentSectionProps) { } }; + const getFileUrl = (key: string) => { + const bucketString = env.VITE_AWS_BUCKET_STRING || ""; + return bucketString + key; + }; + + const isVideoFile = (url: string) => { + return url.match(/\.(mp4|webm|mov)$/i) !== null; + }; + return ( + +
Loading attachments...
+
- - {(key, index) => ( + + {(file) => (
- attachment +
)}
-
- - {(img, index) => ( + 0}> +
+ + + {(file, index) => (
)} diff --git a/src/lib/s3upload.ts b/src/lib/s3upload.ts index beba00a..b192745 100644 --- a/src/lib/s3upload.ts +++ b/src/lib/s3upload.ts @@ -23,7 +23,17 @@ export default async function AddImageToS3( console.log("url: " + uploadURL, "key: " + key); const ext = /^.+\.([^.]+)$/.exec(filename); - const contentType = ext ? `image/${ext[1]}` : "application/octet-stream"; + let contentType = "application/octet-stream"; + + if (ext) { + const extension = ext[1].toLowerCase(); + if (["mp4", "webm", "mov", "quicktime"].includes(extension)) { + contentType = + extension === "mov" ? "video/quicktime" : `video/${extension}`; + } else { + contentType = `image/${extension}`; + } + } const uploadResponse = await fetch(uploadURL, { method: "PUT", @@ -37,7 +47,9 @@ export default async function AddImageToS3( throw new Error("Failed to upload file to S3"); } - if (type === "blog") { + // Only create thumbnails for images + const isImage = contentType.startsWith("image/"); + if (type === "blog" && isImage) { try { const thumbnail = await resizeImage(file, 200, 200, 0.8); diff --git a/src/server/api/routers/misc.ts b/src/server/api/routers/misc.ts index 72d9e2c..8738b7b 100644 --- a/src/server/api/routers/misc.ts +++ b/src/server/api/routers/misc.ts @@ -4,7 +4,8 @@ import { S3Client, GetObjectCommand, PutObjectCommand, - DeleteObjectCommand + DeleteObjectCommand, + ListObjectsV2Command } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { env } from "~/env/server"; @@ -124,6 +125,64 @@ export const miscRouter = createTRPCRouter({ } }), + listAttachments: publicProcedure + .input( + z.object({ + type: z.string(), + title: z.string() + }) + ) + .query(async ({ input }) => { + try { + const credentials = { + accessKeyId: env._AWS_ACCESS_KEY, + secretAccessKey: env._AWS_SECRET_KEY + }; + + const client = new S3Client({ + region: env.AWS_REGION, + credentials: credentials + }); + + const sanitizeForS3 = (str: string) => { + return str + .replace(/\s+/g, "-") + .replace(/[^\w\-\.]/g, "") + .replace(/\-+/g, "-") + .replace(/^-+|-+$/g, ""); + }; + + const sanitizedTitle = sanitizeForS3(input.title); + const prefix = `${input.type}/${sanitizedTitle}/`; + + const command = new ListObjectsV2Command({ + Bucket: env.AWS_S3_BUCKET_NAME, + Prefix: prefix + }); + + const response = await client.send(command); + const files = + response.Contents?.map((item) => ({ + key: item.Key || "", + size: item.Size || 0, + lastModified: item.LastModified?.toISOString() || "" + })) || []; + + // Filter out thumbnail files (ending with -small.ext) + const mainFiles = files.filter( + (file) => !file.key.match(/-small\.(jpg|jpeg|png|gif)$/i) + ); + + return { files: mainFiles }; + } catch (error) { + console.error("Failed to list attachments:", error); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to list attachments" + }); + } + }), + deleteImage: publicProcedure .input( z.object({