Skip to content

Commit 8426c25

Browse files
authored
Merge pull request #415 from CodebilityDev/miguel/improve-feeds
Miguel/improve feeds
2 parents 70a585b + d99ce44 commit 8426c25

11 files changed

Lines changed: 1283 additions & 118 deletions

File tree

‎apps/codebility/app/home/feeds/_components/CreatePostForm.tsx‎

Lines changed: 25 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ import { useFeedsStore } from "@/store/feeds-store";
66
import { uploadImage } from "@/utils/uploadImage";
77
import toast from "react-hot-toast";
88

9-
import { Textarea } from "@codevs/ui/textarea";
10-
119
import { addPost } from "../_services/action";
10+
import MarkdownEditor from "./MarkdownEditor";
11+
import ThumbnailUpload from "./ThumbnailUpload";
1212

1313
const CreatePostForm = ({
1414
className,
@@ -19,6 +19,7 @@ const CreatePostForm = ({
1919
}) => {
2020
const [image, setImage] = useState<File | null>(null);
2121
const [isSubmitting, setIsSubmitting] = useState(false);
22+
const [uploadedImageIds, setUploadedImageIds] = useState<string[]>([]);
2223
const fetchPosts = useFeedsStore((state) => state.fetchPosts);
2324
const { user } = useUserStore();
2425

@@ -40,11 +41,17 @@ const CreatePostForm = ({
4041
if (imageFile) {
4142
image_url = await uploadImage(imageFile, {
4243
bucket: "codebility",
43-
folder: "postImage",
44+
folder: "postImage/thumbnail",
4445
});
4546
}
4647

47-
const newPost = await addPost(title, content, user?.id, image_url!);
48+
const newPost = await addPost({
49+
title,
50+
content,
51+
author_id: user?.id,
52+
image_url,
53+
content_image_ids: uploadedImageIds,
54+
});
4855

4956
// Refresh Posts
5057
await fetchPosts();
@@ -77,26 +84,25 @@ const CreatePostForm = ({
7784
required
7885
className="w-full"
7986
/>
80-
<Textarea
81-
name="content"
82-
placeholder="Enter content"
83-
required
84-
className="h-[600px] w-full"
85-
/>
87+
8688
{/* Optional image input */}
8789

88-
<Input
89-
type="file"
90-
accept="image/*"
91-
onChange={(e) => {
92-
if (e.target.files && e.target.files[0]) {
93-
setImage(e.target.files[0]);
94-
} else {
95-
setImage(null);
96-
}
90+
<ThumbnailUpload onChange={setImage} />
91+
92+
<MarkdownEditor
93+
initialValue={"# Hello"}
94+
onChange={(v) => {
95+
// update hidden input
96+
const hiddenInput = document.querySelector<HTMLInputElement>(
97+
'input[name="content"]',
98+
);
99+
if (hiddenInput) hiddenInput.value = v;
97100
}}
101+
onImagesUploaded={setUploadedImageIds}
98102
/>
99103

104+
<input type="hidden" name="content" value={""} />
105+
100106
<Button type="submit" className="w-full" disabled={isSubmitting}>
101107
{isSubmitting ? "Creating..." : "Create Post"}
102108
</Button>

‎apps/codebility/app/home/feeds/_components/EditPostForm.tsx‎

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { Textarea } from "@codevs/ui/textarea";
1010

1111
import { addPost, editPost } from "../_services/action";
1212
import { PostType } from "../_services/query";
13+
import MarkdownEditor from "./MarkdownEditor";
14+
import ThumbnailUpload from "./ThumbnailUpload";
1315

1416
const EditPostForm = ({
1517
post,
@@ -27,6 +29,8 @@ const EditPostForm = ({
2729
const { user } = useUserStore();
2830
const [title, setTitle] = useState(post.title);
2931
const [content, setContent] = useState(post.content);
32+
const [uploadedImageIds, setUploadedImageIds] = useState<string[]>([]);
33+
const fetchPosts = useFeedsStore((state) => state.fetchPosts);
3034

3135
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
3236
e.preventDefault();
@@ -46,11 +50,17 @@ const EditPostForm = ({
4650
if (imageFile) {
4751
image_url = await uploadImage(imageFile, {
4852
bucket: "codebility",
49-
folder: "postImage",
53+
folder: "postImage/thumbnail",
5054
});
5155
}
5256

53-
const newPost = await editPost(post.id, title, content, image_url!);
57+
const newPost = await editPost({
58+
id: post.id,
59+
title,
60+
content,
61+
image_url: image_url!,
62+
content_image_ids: uploadedImageIds,
63+
});
5464

5565
// Notify
5666
toast.success("Post edited successfully!");
@@ -59,6 +69,7 @@ const EditPostForm = ({
5969
if (onSuccess) onSuccess();
6070

6171
//Refresh post
72+
await fetchPosts();
6273
onPostUpdated();
6374

6475
// Reset form
@@ -86,28 +97,24 @@ const EditPostForm = ({
8697
onChange={(e) => setTitle(e.target.value)}
8798
/>
8899

89-
<Textarea
90-
name="content"
91-
placeholder="Enter content"
92-
required
93-
className="h-[600px] w-full"
94-
value={content}
95-
onChange={(e) => setContent(e.target.value)}
96-
/>
97100
{/* Optional image input */}
98101

99-
<Input
100-
type="file"
101-
accept="image/*"
102-
onChange={(e) => {
103-
if (e.target.files && e.target.files[0]) {
104-
setImage(e.target.files[0]);
105-
} else {
106-
setImage(null);
107-
}
102+
<ThumbnailUpload onChange={setImage} defaultImage={post.image_url} />
103+
104+
<MarkdownEditor
105+
initialValue={content}
106+
onChange={(v) => {
107+
// update hidden input
108+
const hiddenInput = document.querySelector<HTMLInputElement>(
109+
'input[name="content"]',
110+
);
111+
if (hiddenInput) hiddenInput.value = v;
108112
}}
113+
onImagesUploaded={setUploadedImageIds}
109114
/>
110115

116+
<input type="hidden" name="content" value={""} />
117+
111118
<Button type="submit" className="w-full" disabled={isSubmitting}>
112119
{isSubmitting ? "Editing..." : "Edit Post"}
113120
</Button>
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
"use client";
2+
3+
import React, { useEffect, useRef, useState } from "react";
4+
import ReactMarkdown from "react-markdown";
5+
import remarkGfm from "remark-gfm";
6+
7+
import { uploadPostContentImage } from "../_services/action";
8+
9+
export default function MarkdownEditor({
10+
initialValue = "",
11+
onChange,
12+
onImagesUploaded,
13+
}: {
14+
initialValue?: string;
15+
onChange?: (value: string) => void;
16+
onImagesUploaded?: (ids: string[]) => void;
17+
}) {
18+
const [tab, setTab] = useState<"write" | "preview">("write");
19+
const [value, setValue] = useState(initialValue);
20+
const [uploadedIds, setUploadedIds] = useState<string[]>([]);
21+
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
22+
23+
useEffect(() => {
24+
if (onChange) onChange(value);
25+
}, [value, onChange]);
26+
27+
useEffect(() => {
28+
// Auto-resize textarea for nicer UX
29+
const ta = textareaRef.current;
30+
if (!ta) return;
31+
ta.style.height = "0px";
32+
ta.style.height = Math.min(ta.scrollHeight, 800) + "px"; // limit max-height
33+
}, [value, tab]);
34+
35+
// notify parent whenever uploadedIds changes
36+
useEffect(() => {
37+
if (onImagesUploaded) onImagesUploaded(uploadedIds);
38+
}, [uploadedIds, onImagesUploaded]);
39+
40+
const handlePaste = async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
41+
if (!e.clipboardData || e.clipboardData.files.length === 0) return;
42+
43+
const file = e.clipboardData.files[0];
44+
if (!file || !file.type.startsWith("image/")) return;
45+
46+
e.preventDefault();
47+
48+
const placeholderMarkdown = `![Uploading image...]()`;
49+
const textarea = textareaRef.current;
50+
if (!textarea) return;
51+
52+
const start = textarea.selectionStart;
53+
const end = textarea.selectionEnd;
54+
const newValue =
55+
value.substring(0, start) + placeholderMarkdown + value.substring(end);
56+
57+
setValue(newValue);
58+
59+
try {
60+
const uploaded = await uploadPostContentImage(file);
61+
62+
const finalMarkdown = `![image](${uploaded.image_url})`;
63+
64+
setValue((current) =>
65+
current.replace(placeholderMarkdown, finalMarkdown),
66+
);
67+
68+
// store the uploaded ID
69+
setUploadedIds((prev) => [...prev, uploaded.id]);
70+
} catch (err) {
71+
console.error("Image upload failed:", err);
72+
setValue((current) =>
73+
current.replace(placeholderMarkdown, "![Upload failed](#)"),
74+
);
75+
}
76+
};
77+
78+
return (
79+
<div className="mx-auto w-full max-w-4xl rounded-2xl bg-white p-4 shadow dark:bg-gray-900">
80+
<div className="mb-3 flex items-center justify-between">
81+
<div className="flex gap-1 rounded-md bg-gray-100 p-1 dark:bg-gray-800">
82+
<button
83+
type="button"
84+
onClick={() => setTab("write")}
85+
className={`rounded-md px-4 py-1 text-sm font-medium ${
86+
tab === "write"
87+
? "bg-white shadow dark:bg-gray-700"
88+
: "text-gray-600 dark:text-gray-300"
89+
}`}
90+
>
91+
Write
92+
</button>
93+
<button
94+
type="button"
95+
onClick={() => setTab("preview")}
96+
className={`rounded-md px-4 py-1 text-sm font-medium ${
97+
tab === "preview"
98+
? "bg-white shadow dark:bg-gray-700"
99+
: "text-gray-600 dark:text-gray-300"
100+
}`}
101+
>
102+
Preview
103+
</button>
104+
</div>
105+
<div className="text-xs text-gray-500 dark:text-gray-400">
106+
Markdown Editor
107+
</div>
108+
</div>
109+
110+
<div className="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700">
111+
{tab === "write" ? (
112+
<div className="p-4">
113+
<textarea
114+
ref={textareaRef}
115+
value={value}
116+
onChange={(e) => setValue(e.target.value)}
117+
onPaste={handlePaste}
118+
placeholder="# Start typing markdown..."
119+
className="max-h-[600px] min-h-[160px] w-full resize-none bg-transparent text-sm leading-relaxed text-gray-900 outline-none dark:text-gray-100"
120+
/>
121+
</div>
122+
) : (
123+
<div className="prose prose-sm dark:prose-invert max-h-[600px] min-h-[160px] max-w-none overflow-y-auto p-4 text-sm">
124+
{value.trim() ? (
125+
<ReactMarkdown remarkPlugins={[remarkGfm]}>{value}</ReactMarkdown>
126+
) : (
127+
<div className="text-gray-500">
128+
Nothing to preview — start writing markdown in the{" "}
129+
<strong>Write</strong> tab.
130+
</div>
131+
)}
132+
</div>
133+
)}
134+
</div>
135+
136+
<div className="mt-3 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
137+
<div>Characters: {value.length}</div>
138+
<div className="flex gap-2">
139+
<button
140+
type="button"
141+
onClick={() => {
142+
setValue("");
143+
setTab("write");
144+
}}
145+
className="rounded-md bg-red-50 px-3 py-1 text-red-600 dark:bg-red-900/30 dark:text-red-300"
146+
>
147+
Clear
148+
</button>
149+
<button
150+
type="button"
151+
onClick={() => setTab(tab === "write" ? "preview" : "write")}
152+
className="rounded-md bg-gray-50 px-3 py-1 text-gray-700 dark:bg-gray-800 dark:text-gray-200"
153+
>
154+
{tab === "write" ? "Show Preview" : "Edit"}
155+
</button>
156+
</div>
157+
</div>
158+
</div>
159+
);
160+
}

‎apps/codebility/app/home/feeds/_components/Post.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export default function Post({ post, isMentor, onDelete }: PostProps) {
4646
const openModal = () => setIsModalOpen(true);
4747
const closeModal = () => setIsModalOpen(false);
4848
return (
49-
<>
49+
<div>
5050
<Box
5151
className="group relative flex h-[400px] cursor-pointer flex-col justify-between p-3"
5252
onClick={openModal}
@@ -110,6 +110,6 @@ export default function Post({ post, isMentor, onDelete }: PostProps) {
110110
onClose={closeModal}
111111
postId={post.id}
112112
/>
113-
</>
113+
</div>
114114
);
115115
}

‎apps/codebility/app/home/feeds/_components/PostView.tsx‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import { defaultAvatar } from "@/public/assets/images";
1111
import { useUserStore } from "@/store/codev-store";
1212
import { createClientClientComponent } from "@/utils/supabase/client";
1313
import { format } from "date-fns";
14+
import ReactMarkdown from "react-markdown";
15+
import remarkGfm from "remark-gfm";
1416

1517
import type { PostType } from "../_services/query";
1618
import PostUpvote from "../_components/PostUpvote";
@@ -117,14 +119,12 @@ export default function PostView({ postId }: PostViewProps) {
117119

118120
<PostUpvote post={post} />
119121

120-
<img
121-
src={post.image_url || "/assets/images/bg-certificate.png"}
122-
alt="Post image"
123-
className="h-full w-full object-cover"
124-
/>
125-
<p className="mb-4 mt-4 text-justify text-gray-600 dark:text-gray-400">
126-
{post.content}
127-
</p>
122+
<div className="prose prose-sm dark:prose-invert max-h-[800px] min-h-[160px] max-w-none overflow-y-auto p-4 text-sm">
123+
<ReactMarkdown remarkPlugins={[remarkGfm]}>
124+
{`![Post image](${post.image_url || "/assets/images/bg-certificate.png"})\n\n${post.content}`}
125+
</ReactMarkdown>
126+
</div>
127+
128128
{isMentor && (
129129
<Button className="mb-4 mt-4 w-auto" onClick={openModal}>
130130
Edit Post

0 commit comments

Comments
 (0)