load previous attachments, video support
This commit is contained in:
@@ -3,6 +3,7 @@ import Dropzone from "./Dropzone";
|
|||||||
import XCircle from "~/components/icons/XCircle";
|
import XCircle from "~/components/icons/XCircle";
|
||||||
import AddImageToS3 from "~/lib/s3upload";
|
import AddImageToS3 from "~/lib/s3upload";
|
||||||
import { env } from "~/env/client";
|
import { env } from "~/env/client";
|
||||||
|
import { api } from "~/lib/api";
|
||||||
|
|
||||||
export interface AddAttachmentSectionProps {
|
export interface AddAttachmentSectionProps {
|
||||||
type: "blog" | "project";
|
type: "blog" | "project";
|
||||||
@@ -12,73 +13,85 @@ export interface AddAttachmentSectionProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AddAttachmentSection(props: AddAttachmentSectionProps) {
|
export default function AddAttachmentSection(props: AddAttachmentSectionProps) {
|
||||||
const [images, setImages] = createSignal<File[]>([]);
|
const [files, setFiles] = createSignal<File[]>([]);
|
||||||
const [imageHolder, setImageHolder] = createSignal<string[]>([]);
|
const [s3Files, setS3Files] = createSignal<
|
||||||
const [newImageHolder, setNewImageHolder] = createSignal<string[]>([]);
|
Array<{ key: string; size: number; lastModified: string }>
|
||||||
const [newImageHolderKeys, setNewImageHolderKeys] = createSignal<string[]>(
|
>([]);
|
||||||
[]
|
const [newFileHolder, setNewFileHolder] = createSignal<string[]>([]);
|
||||||
);
|
const [newFileHolderKeys, setNewFileHolderKeys] = createSignal<string[]>([]);
|
||||||
|
const [fileTypes, setFileTypes] = createSignal<string[]>([]);
|
||||||
|
const [loading, setLoading] = createSignal(false);
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (props.existingAttachments) {
|
if (props.postTitle) {
|
||||||
const imgStringArr = props.existingAttachments.split(",");
|
loadAttachments();
|
||||||
setImageHolder(imgStringArr);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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[]) => {
|
const handleImageDrop = async (acceptedFiles: File[]) => {
|
||||||
if (props.postTitle) {
|
if (props.postTitle) {
|
||||||
for (const file of acceptedFiles) {
|
for (const file of acceptedFiles) {
|
||||||
setImages((prev) => [...prev, file]);
|
setFiles((prev) => [...prev, file]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const key = await AddImageToS3(file, props.postTitle, props.type);
|
const key = await AddImageToS3(file, props.postTitle, props.type);
|
||||||
if (key) {
|
if (key) {
|
||||||
setNewImageHolderKeys((prev) => [...prev, key]);
|
setNewFileHolderKeys((prev) => [...prev, key]);
|
||||||
|
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = () => {
|
reader.onload = () => {
|
||||||
const str = reader.result;
|
const str = reader.result;
|
||||||
if (str) {
|
if (str) {
|
||||||
setNewImageHolder((prev) => [...prev, str as string]);
|
setNewFileHolder((prev) => [...prev, str as string]);
|
||||||
|
setFileTypes((prev) => [...prev, file.type]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
|
|
||||||
|
// Refresh the S3 file list
|
||||||
|
await loadAttachments();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to upload image:", err);
|
console.error("Failed to upload file:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeImage = async (index: number, key: string) => {
|
const removeImage = async (key: string) => {
|
||||||
if (props.postId && props.existingAttachments) {
|
|
||||||
const imgStringArr = props.existingAttachments.split(",");
|
|
||||||
const newString = imgStringArr.filter((str) => str !== key).join(",");
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch("/api/trpc/misc.deleteImage", {
|
await fetch("/api/trpc/misc.simpleDeleteImage", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ key })
|
||||||
key,
|
|
||||||
newAttachmentString: newString,
|
|
||||||
type: props.type,
|
|
||||||
id: props.postId
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setImageHolder((prev) => prev.filter((_, i) => i !== index));
|
// Refresh the S3 file list
|
||||||
|
await loadAttachments();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to delete image:", err);
|
console.error("Failed to delete file:", err);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeNewImage = async (index: number, key: string) => {
|
const removeNewImage = async (index: number, key: string) => {
|
||||||
setImages((prev) => prev.filter((_, i) => i !== index));
|
setFiles((prev) => prev.filter((_, i) => i !== index));
|
||||||
setNewImageHolder((prev) => prev.filter((_, i) => i !== index));
|
setNewFileHolder((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
setFileTypes((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch("/api/trpc/misc.simpleDeleteImage", {
|
await fetch("/api/trpc/misc.simpleDeleteImage", {
|
||||||
@@ -87,7 +100,7 @@ export default function AddAttachmentSection(props: AddAttachmentSectionProps) {
|
|||||||
body: JSON.stringify({ key })
|
body: JSON.stringify({ key })
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} 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 (
|
return (
|
||||||
<Show
|
<Show
|
||||||
when={props.postTitle}
|
when={props.postTitle}
|
||||||
@@ -114,19 +136,22 @@ export default function AddAttachmentSection(props: AddAttachmentSectionProps) {
|
|||||||
<div class="flex justify-center">
|
<div class="flex justify-center">
|
||||||
<Dropzone
|
<Dropzone
|
||||||
onDrop={handleImageDrop}
|
onDrop={handleImageDrop}
|
||||||
accept="image/jpg, image/jpeg, image/png"
|
accept="image/jpg, image/jpeg, image/png, video/mp4, video/webm, video/quicktime"
|
||||||
fileHolder={null}
|
fileHolder={null}
|
||||||
preSet={null}
|
preSet={null}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div class="-mx-24 grid grid-cols-6 gap-4">
|
||||||
<For each={imageHolder()}>
|
<For each={s3Files()}>
|
||||||
{(key, index) => (
|
{(file) => (
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
type="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={() => removeImage(index(), key)}
|
onClick={() => removeImage(file.key)}
|
||||||
>
|
>
|
||||||
<XCircle
|
<XCircle
|
||||||
height={24}
|
height={24}
|
||||||
@@ -135,19 +160,42 @@ export default function AddAttachmentSection(props: AddAttachmentSectionProps) {
|
|||||||
strokeWidth={1}
|
strokeWidth={1}
|
||||||
/>
|
/>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
<Show when={newFileHolder().length > 0}>
|
||||||
<div class="border-surface2 mx-auto border-r" />
|
<div class="border-surface2 mx-auto border-r" />
|
||||||
<For each={newImageHolder()}>
|
</Show>
|
||||||
{(img, index) => (
|
<For each={newFileHolder()}>
|
||||||
|
{(file, index) => (
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
type="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={() =>
|
onClick={() =>
|
||||||
removeNewImage(index(), newImageHolderKeys()[index()])
|
removeNewImage(index(), newFileHolderKeys()[index()])
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<XCircle
|
<XCircle
|
||||||
@@ -160,14 +208,25 @@ export default function AddAttachmentSection(props: AddAttachmentSectionProps) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
copyToClipboard(newImageHolderKeys()[index()] as string)
|
copyToClipboard(newFileHolderKeys()[index()] as string)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<Show
|
||||||
|
when={fileTypes()[index()]?.startsWith("video/")}
|
||||||
|
fallback={
|
||||||
<img
|
<img
|
||||||
src={img}
|
src={file}
|
||||||
class="mx-4 my-auto h-36 w-36"
|
class="mx-4 my-auto h-36 w-36 object-cover"
|
||||||
alt="new attachment"
|
alt="new attachment"
|
||||||
/>
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
src={file}
|
||||||
|
class="mx-4 my-auto h-36 w-36 object-cover"
|
||||||
|
controls
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -23,7 +23,17 @@ export default async function AddImageToS3(
|
|||||||
console.log("url: " + uploadURL, "key: " + key);
|
console.log("url: " + uploadURL, "key: " + key);
|
||||||
|
|
||||||
const ext = /^.+\.([^.]+)$/.exec(filename);
|
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, {
|
const uploadResponse = await fetch(uploadURL, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
@@ -37,7 +47,9 @@ export default async function AddImageToS3(
|
|||||||
throw new Error("Failed to upload file to S3");
|
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 {
|
try {
|
||||||
const thumbnail = await resizeImage(file, 200, 200, 0.8);
|
const thumbnail = await resizeImage(file, 200, 200, 0.8);
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import {
|
|||||||
S3Client,
|
S3Client,
|
||||||
GetObjectCommand,
|
GetObjectCommand,
|
||||||
PutObjectCommand,
|
PutObjectCommand,
|
||||||
DeleteObjectCommand
|
DeleteObjectCommand,
|
||||||
|
ListObjectsV2Command
|
||||||
} from "@aws-sdk/client-s3";
|
} from "@aws-sdk/client-s3";
|
||||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||||
import { env } from "~/env/server";
|
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
|
deleteImage: publicProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
|
|||||||
Reference in New Issue
Block a user