Skip to content

Commit 7ae35f2

Browse files
committed
Rich text editor with images s3 bucket for code items description
1 parent 2e4fc5c commit 7ae35f2

22 files changed

+6644
-1710
lines changed

app/api/code-items/[id]/route.ts

+19-1
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { NextRequest, NextResponse } from "next/server";
2-
import { connectDb } from "@/lib";
2+
import { connectDb, deleteFile } from "@/lib";
33
import { ZodError, z } from "zod";
44
import CodeItem from "@/models/code-item";
55
import { getServerSession } from "next-auth";
66
import { authConfig } from "@/configs";
7+
import { extractImageUrls, mapUrlsToKeys } from "@/helpers";
78

89
interface UpdateCodeItemRequest {
910
name: string;
@@ -52,6 +53,21 @@ const deleteCodeItem = async (labelId: string): Promise<void> => {
5253
await CodeItem.findOneAndDelete({ _id: labelId });
5354
};
5455

56+
const deleteImagesFromS3 = async (codeItemId: string) => {
57+
const codeItem = await CodeItem.findById(codeItemId);
58+
59+
// Extract image URLs from the content
60+
const imageUrls = extractImageUrls(codeItem?.description || "");
61+
62+
if (imageUrls.length > 0) {
63+
// Map URLs to keys for S3 deletion
64+
const keys = mapUrlsToKeys(imageUrls);
65+
66+
// Delete all images from S3
67+
await Promise.all(keys.map((key) => deleteFile({ key })));
68+
}
69+
};
70+
5571
export async function PUT(
5672
req: NextRequest,
5773
{ params }: { params: { id: string } }
@@ -94,6 +110,8 @@ export async function DELETE(
94110

95111
const codeItemId = params.id;
96112

113+
await deleteImagesFromS3(codeItemId);
114+
97115
await deleteCodeItem(codeItemId);
98116

99117
return NextResponse.json({ success: true });

app/api/code-items/route.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ export async function POST(req: NextRequest) {
128128
.object({
129129
name: z.string().min(1),
130130
category_id: z.string().min(1),
131-
description: z.string(),
131+
description: z.string().optional(),
132132
language: z.string().min(1),
133133
code: z.string(),
134134
label_ids: z.array(z.string()).optional(),

app/api/images/route.ts

+56
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { Image, IMAGES_API_ENDPOINT } from "@/types";
3+
import { deleteFile, uploadFile } from "@/lib/s3";
4+
import { mapUrlsToKeys } from "@/helpers";
5+
6+
export async function POST(req: NextRequest) {
7+
try {
8+
const formData = await req.formData();
9+
const files = formData.getAll(IMAGES_API_ENDPOINT) as File[];
10+
11+
const uploadPromises = files.map(async (file) => {
12+
const buffer = await file.arrayBuffer();
13+
const fileName = `${Date.now()}-${file.name}`;
14+
const mimeType = file.type;
15+
16+
await uploadFile({
17+
file: Buffer.from(buffer),
18+
fileName,
19+
mimeType,
20+
});
21+
22+
const url = `https://${process.env.S3_BUCKET_NAME}.s3.${process.env.MY_AWS_REGION}.amazonaws.com/${fileName}`;
23+
24+
return {
25+
url,
26+
name: file.name,
27+
};
28+
});
29+
30+
const uploadedImages: Image[] = await Promise.all(uploadPromises);
31+
32+
return NextResponse.json({ success: true, data: uploadedImages });
33+
} catch (error) {
34+
console.error("Upload images error:", error);
35+
return NextResponse.json({ success: false, error: "Image upload failed" });
36+
}
37+
}
38+
39+
export async function DELETE(req: NextRequest) {
40+
try {
41+
const { urls } = await req.json();
42+
43+
// Extract and decode the keys from the URLs
44+
const keys = mapUrlsToKeys(urls);
45+
46+
await Promise.all(keys.map((key) => deleteFile({ key })));
47+
48+
return NextResponse.json({ success: true });
49+
} catch (error) {
50+
console.error("Image deletion error:", error);
51+
return NextResponse.json({
52+
success: false,
53+
error: "Image deletion failed",
54+
});
55+
}
56+
}

app/code-items/show/[id]/show.tsx

+10-4
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@ import ContentCopyIcon from "@mui/icons-material/ContentCopy";
88
import { useQuery } from "@tanstack/react-query";
99
import { fetchOneCodeItem } from "@/api/codeItems/fetchOneCodeItem";
1010
import { CODEITEMS_API_ENDPOINT } from "@/types";
11-
import { Button, CodeEditorView, DeleteCodeItemButton } from "@/components";
11+
import {
12+
Button,
13+
CodeEditorView,
14+
DeleteCodeItemButton,
15+
RichTextEditorView,
16+
} from "@/components";
1217
import { useProgress } from "@/providers/ProgressBarProvider";
1318
import { useSnackbar } from "notistack";
1419

@@ -88,9 +93,10 @@ export const ShowCodeItem = () => {
8893
</Typography>
8994

9095
{description && (
91-
<Typography fontSize={20} sx={typographyStyles}>
92-
{description}
93-
</Typography>
96+
<RichTextEditorView
97+
content={codeItem.description ?? ""}
98+
readonly={true}
99+
/>
94100
)}
95101

96102
{code && (
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { uploadImages } from "@/helpers/uploadImages";
2+
import { useTheme } from "@mui/material";
3+
import {
4+
MenuButtonAddTable,
5+
MenuButtonBold,
6+
MenuButtonCode,
7+
MenuButtonCodeBlock,
8+
MenuButtonEditLink,
9+
MenuButtonHorizontalRule,
10+
MenuButtonImageUpload,
11+
MenuButtonItalic,
12+
MenuButtonRedo,
13+
MenuButtonTaskList,
14+
MenuButtonTextColor,
15+
MenuButtonUnderline,
16+
MenuButtonUndo,
17+
MenuControlsContainer,
18+
MenuDivider,
19+
MenuSelectFontSize,
20+
MenuSelectHeading,
21+
MenuSelectTextAlign,
22+
} from "mui-tiptap";
23+
24+
export default function EditorMenuControls() {
25+
const theme = useTheme();
26+
27+
return (
28+
<MenuControlsContainer>
29+
<MenuSelectHeading />
30+
31+
<MenuDivider />
32+
33+
<MenuSelectFontSize />
34+
35+
<MenuDivider />
36+
37+
<MenuButtonBold />
38+
39+
<MenuButtonItalic />
40+
41+
<MenuButtonUnderline />
42+
43+
<MenuDivider />
44+
45+
<MenuButtonTextColor
46+
defaultTextColor={theme.palette.text.primary}
47+
swatchColors={[
48+
{ value: "#000000", label: "Black" },
49+
{ value: "#ffffff", label: "White" },
50+
{ value: "#888888", label: "Grey" },
51+
{ value: "#ff0000", label: "Red" },
52+
{ value: "#ff9900", label: "Orange" },
53+
{ value: "#ffff00", label: "Yellow" },
54+
{ value: "#00d000", label: "Green" },
55+
{ value: "#0000ff", label: "Blue" },
56+
]}
57+
/>
58+
59+
<MenuDivider />
60+
61+
<MenuSelectTextAlign />
62+
63+
<MenuDivider />
64+
65+
<MenuButtonEditLink />
66+
67+
<MenuDivider />
68+
69+
<MenuButtonTaskList />
70+
71+
<MenuDivider />
72+
73+
<MenuButtonCode />
74+
75+
<MenuButtonCodeBlock />
76+
77+
<MenuDivider />
78+
79+
<MenuButtonImageUpload
80+
onUploadFiles={(files) => {
81+
return uploadImages(files);
82+
}}
83+
type="button"
84+
/>
85+
86+
<MenuDivider />
87+
88+
<MenuButtonHorizontalRule />
89+
90+
<MenuDivider />
91+
92+
<MenuButtonAddTable />
93+
94+
<MenuDivider />
95+
96+
<MenuButtonUndo />
97+
98+
<MenuButtonRedo />
99+
</MenuControlsContainer>
100+
);
101+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { useMemo, useRef } from "react";
2+
import { type RichTextEditorRef } from "mui-tiptap";
3+
import { useController } from "react-hook-form";
4+
import { extractImageUrls } from "@/helpers/extractImageUrls";
5+
import { RichTextEditorView } from "./RichTextEditorView";
6+
7+
interface RichTextEditorProps {
8+
name: string;
9+
onCollectRemovedImages: (urls: string[]) => void;
10+
}
11+
12+
export const RichTextEditor = ({
13+
name,
14+
onCollectRemovedImages,
15+
}: RichTextEditorProps) => {
16+
const { field } = useController({
17+
name,
18+
});
19+
20+
const rteRef = useRef<RichTextEditorRef>(null);
21+
22+
const initialImages = useMemo(
23+
() => extractImageUrls(field.value || ""),
24+
[field.value]
25+
);
26+
27+
const handleChange = async () => {
28+
const htmlContent = rteRef.current?.editor?.getHTML() || "";
29+
const currentImages = extractImageUrls(htmlContent);
30+
31+
// Find removed images
32+
const removedImages = initialImages.filter(
33+
(img) => !currentImages.includes(img)
34+
);
35+
36+
if (removedImages.length > 0) {
37+
onCollectRemovedImages(removedImages);
38+
}
39+
40+
field.onChange(htmlContent);
41+
};
42+
43+
return (
44+
<RichTextEditorView
45+
content={field.value}
46+
onUpdate={handleChange}
47+
ref={rteRef}
48+
/>
49+
);
50+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { Box } from "@mui/material";
2+
import { forwardRef } from "react";
3+
import {
4+
RichTextEditor as Editor,
5+
LinkBubbleMenu,
6+
RichTextEditorRef,
7+
TableBubbleMenu,
8+
} from "mui-tiptap";
9+
import EditorMenuControls from "./EditorMenuControls";
10+
import useExtensions from "./useExtensions";
11+
import { EditorEvents } from "@tiptap/core";
12+
13+
interface RichTextEditorViewProps {
14+
content: string;
15+
onUpdate?: (props: EditorEvents["update"]) => void;
16+
readonly?: boolean;
17+
}
18+
19+
export const RichTextEditorView = forwardRef<
20+
RichTextEditorRef,
21+
RichTextEditorViewProps
22+
>((props, ref) => {
23+
const { content, readonly, onUpdate } = props;
24+
25+
const extensions = useExtensions({
26+
placeholder: "Add your description here...",
27+
});
28+
29+
return (
30+
<Box
31+
sx={{
32+
width: 1,
33+
"& .ProseMirror": {
34+
"& h1, & h2, & h3, & h4, & h5, & h6": {
35+
scrollMarginTop: 50,
36+
},
37+
},
38+
}}
39+
>
40+
<Editor
41+
ref={ref}
42+
extensions={extensions}
43+
content={content}
44+
editable={!readonly}
45+
onUpdate={onUpdate}
46+
immediatelyRender={false}
47+
renderControls={() => (readonly ? null : <EditorMenuControls />)}
48+
RichTextFieldProps={{
49+
variant: readonly ? "standard" : "outlined",
50+
}}
51+
>
52+
{() => (
53+
<>
54+
<LinkBubbleMenu />
55+
<TableBubbleMenu />
56+
</>
57+
)}
58+
</Editor>
59+
</Box>
60+
);
61+
});
62+
RichTextEditorView.displayName = "RichTextEditorView";

components/RichTextEditor/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from "./RichTextEditor";
2+
export * from "./RichTextEditorView";

0 commit comments

Comments
 (0)