load previous attachments, video support

This commit is contained in:
Michael Freno
2026-01-05 18:52:50 -05:00
parent 5222e82b9a
commit 61303969e8
3 changed files with 186 additions and 56 deletions

View File

@@ -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<File[]>([]);
const [imageHolder, setImageHolder] = createSignal<string[]>([]);
const [newImageHolder, setNewImageHolder] = createSignal<string[]>([]);
const [newImageHolderKeys, setNewImageHolderKeys] = createSignal<string[]>(
[]
);
const [files, setFiles] = createSignal<File[]>([]);
const [s3Files, setS3Files] = createSignal<
Array<{ key: string; size: number; lastModified: string }>
>([]);
const [newFileHolder, setNewFileHolder] = createSignal<string[]>([]);
const [newFileHolderKeys, setNewFileHolderKeys] = createSignal<string[]>([]);
const [fileTypes, setFileTypes] = createSignal<string[]>([]);
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 (
<Show
when={props.postTitle}
@@ -114,19 +136,22 @@ export default function AddAttachmentSection(props: AddAttachmentSectionProps) {
<div class="flex justify-center">
<Dropzone
onDrop={handleImageDrop}
accept="image/jpg, image/jpeg, image/png"
accept="image/jpg, image/jpeg, image/png, video/mp4, video/webm, video/quicktime"
fileHolder={null}
preSet={null}
/>
</div>
<Show when={loading()}>
<div class="text-subtext0 py-4 text-center">Loading attachments...</div>
</Show>
<div class="-mx-24 grid grid-cols-6 gap-4">
<For each={imageHolder()}>
{(key, index) => (
<For each={s3Files()}>
{(file) => (
<div>
<button
type="button"
class="hover:bg-crust hover:bg-opacity-80 absolute ml-4 pb-[120px]"
onClick={() => removeImage(index(), key)}
class="hover:bg-crust hover:bg-opacity-80 absolute z-10 ml-4 pb-[120px]"
onClick={() => removeImage(file.key)}
>
<XCircle
height={24}
@@ -135,19 +160,42 @@ export default function AddAttachmentSection(props: AddAttachmentSectionProps) {
strokeWidth={1}
/>
</button>
<img src={key} class="mx-4 my-auto h-36 w-36" alt="attachment" />
<button
type="button"
onClick={() => copyToClipboard(file.key)}
class="relative"
>
<Show
when={isVideoFile(file.key)}
fallback={
<img
src={getFileUrl(file.key)}
class="mx-4 my-auto h-36 w-36 object-cover"
alt="attachment"
/>
}
>
<video
src={getFileUrl(file.key)}
class="mx-4 my-auto h-36 w-36 object-cover"
controls
/>
</Show>
</button>
</div>
)}
</For>
<div class="border-surface2 mx-auto border-r" />
<For each={newImageHolder()}>
{(img, index) => (
<Show when={newFileHolder().length > 0}>
<div class="border-surface2 mx-auto border-r" />
</Show>
<For each={newFileHolder()}>
{(file, index) => (
<div>
<button
type="button"
class="hover:bg-crust hover:bg-opacity-80 absolute ml-4 pb-[120px]"
class="hover:bg-crust hover:bg-opacity-80 absolute z-10 ml-4 pb-[120px]"
onClick={() =>
removeNewImage(index(), newImageHolderKeys()[index()])
removeNewImage(index(), newFileHolderKeys()[index()])
}
>
<XCircle
@@ -160,14 +208,25 @@ export default function AddAttachmentSection(props: AddAttachmentSectionProps) {
<button
type="button"
onClick={() =>
copyToClipboard(newImageHolderKeys()[index()] as string)
copyToClipboard(newFileHolderKeys()[index()] as string)
}
>
<img
src={img}
class="mx-4 my-auto h-36 w-36"
alt="new attachment"
/>
<Show
when={fileTypes()[index()]?.startsWith("video/")}
fallback={
<img
src={file}
class="mx-4 my-auto h-36 w-36 object-cover"
alt="new attachment"
/>
}
>
<video
src={file}
class="mx-4 my-auto h-36 w-36 object-cover"
controls
/>
</Show>
</button>
</div>
)}

View File

@@ -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);

View File

@@ -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({