Skip to content

Commit d98b34a

Browse files
committed
add post tags, display in post card and post view, add/edit tags in create/edit post
1 parent 1c166bd commit d98b34a

8 files changed

Lines changed: 278 additions & 13 deletions

File tree

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import toast from "react-hot-toast";
88

99
import { addPost } from "../_services/action";
1010
import MarkdownEditor from "./MarkdownEditor";
11+
import TagSelector from "./TagSelector";
1112
import ThumbnailUpload from "./ThumbnailUpload";
1213

1314
const CreatePostForm = ({
@@ -20,6 +21,8 @@ const CreatePostForm = ({
2021
const [image, setImage] = useState<File | null>(null);
2122
const [isSubmitting, setIsSubmitting] = useState(false);
2223
const [uploadedImageIds, setUploadedImageIds] = useState<string[]>([]);
24+
const [selectedTagIds, setSelectedTagIds] = useState<number[]>([]);
25+
2326
const fetchPosts = useFeedsStore((state) => state.fetchPosts);
2427
const { user } = useUserStore();
2528

@@ -45,12 +48,13 @@ const CreatePostForm = ({
4548
});
4649
}
4750

48-
const newPost = await addPost({
51+
await addPost({
4952
title,
5053
content,
5154
author_id: user?.id,
5255
image_url,
5356
content_image_ids: uploadedImageIds,
57+
tag_ids: selectedTagIds,
5458
});
5559

5660
// Refresh Posts
@@ -102,6 +106,8 @@ const CreatePostForm = ({
102106
onImagesUploaded={setUploadedImageIds}
103107
/>
104108

109+
<TagSelector onChange={(tags) => setSelectedTagIds(tags)} />
110+
105111
<input type="hidden" name="content" value={""} />
106112

107113
<Button type="submit" className="w-full" disabled={isSubmitting}>

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { Textarea } from "@codevs/ui/textarea";
1111
import { addPost, editPost } from "../_services/action";
1212
import { PostType } from "../_services/query";
1313
import MarkdownEditor from "./MarkdownEditor";
14+
import TagSelector from "./TagSelector";
1415
import ThumbnailUpload from "./ThumbnailUpload";
1516

1617
const EditPostForm = ({
@@ -30,6 +31,10 @@ const EditPostForm = ({
3031
const [title, setTitle] = useState(post.title);
3132
const [content, setContent] = useState(post.content);
3233
const [uploadedImageIds, setUploadedImageIds] = useState<string[]>([]);
34+
const [selectedTagIds, setSelectedTagIds] = useState<number[]>(
35+
post.tags.map((t) => Number(t.tag_id)),
36+
);
37+
3338
const fetchPosts = useFeedsStore((state) => state.fetchPosts);
3439

3540
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
@@ -54,12 +59,13 @@ const EditPostForm = ({
5459
});
5560
}
5661

57-
const newPost = await editPost({
62+
await editPost({
5863
id: post.id,
5964
title,
6065
content,
6166
image_url: image_url!,
6267
content_image_ids: uploadedImageIds,
68+
tag_ids: selectedTagIds,
6369
});
6470

6571
// Notify
@@ -114,6 +120,11 @@ const EditPostForm = ({
114120
onImagesUploaded={setUploadedImageIds}
115121
/>
116122

123+
<TagSelector
124+
selectedTags={selectedTagIds}
125+
onChange={(tags) => setSelectedTagIds(tags)}
126+
/>
127+
117128
<input type="hidden" name="content" value={""} />
118129

119130
<Button type="submit" className="w-full" disabled={isSubmitting}>

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

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { deletePost } from "../_services/action";
1313
import { PostType } from "../_services/query";
1414
import { DeleteDialog } from "./DeleteDialog";
1515
import PostCommentCount from "./PostCommentCount";
16+
import PostTags from "./PostTags";
1617
import PostUpvote from "./PostUpvote";
1718

1819
interface PostProps {
@@ -70,7 +71,7 @@ export default function Post({ post, isAdmin, onDelete }: PostProps) {
7071
return (
7172
<div>
7273
<Box
73-
className="group relative flex h-[400px] cursor-pointer flex-col justify-between rounded-xl p-3"
74+
className="group relative flex h-[440px] cursor-pointer flex-col justify-between rounded-xl p-3"
7475
onClick={openModal}
7576
>
7677
{/* Delete Button in top-right */}
@@ -105,25 +106,28 @@ export default function Post({ post, isAdmin, onDelete }: PostProps) {
105106
</div>
106107

107108
<div className="h-[60px] flex-grow">
108-
<p className="mt-2 line-clamp-2 text-xl font-semibold text-gray-700 dark:text-gray-300">
109+
<p className="mt-2 line-clamp-2 text-xl font-semibold leading-snug text-gray-700 dark:text-gray-300">
109110
{post.title}
110111
</p>
111112
</div>
113+
<div className="mb-2">
114+
<PostTags post={post} minimal={true} />
115+
</div>
112116

113117
<p className="text-sm text-gray-500 dark:text-gray-400">
114118
{format(new Date(post.created_at), "MMM d, yyyy")}
115119
</p>
116120
</div>
117121

118-
<div className="h-60 w-full overflow-hidden rounded-sm">
122+
<div className="my-2 h-60 w-full overflow-hidden rounded-sm">
119123
<img
120124
src={post.image_url || "/assets/images/bg-certificate.png"}
121125
alt="Post image"
122126
className="h-full w-full object-cover"
123127
/>
124128
</div>
125129

126-
<div className="mx-4 mt-4 flex items-center space-x-4 text-gray-600 dark:text-gray-400">
130+
<div className="mx-4 flex items-center space-x-4 text-gray-600 dark:text-gray-400">
127131
<PostUpvote post={post} />
128132

129133
<PostCommentCount post={post} />
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"use client";
2+
3+
import type { PostType } from "../_services/query";
4+
5+
interface PostTagsProps {
6+
post: PostType;
7+
minimal?: boolean;
8+
}
9+
10+
export default function PostTags({ post, minimal = false }: PostTagsProps) {
11+
const tags = post.tags ?? [];
12+
13+
if (!tags.length && !minimal) return null;
14+
15+
// Determine which tags to display
16+
const displayTags = minimal ? tags.slice(0, 3) : tags;
17+
const hiddenCount = minimal ? tags.length - displayTags.length : 0;
18+
19+
return (
20+
<div className="flex flex-wrap items-center gap-2">
21+
{displayTags.map((tag) => (
22+
<span
23+
key={tag.tag_id}
24+
className="
25+
flex h-6
26+
items-center gap-2 rounded-md border
27+
border-gray-300 bg-gray-50 px-2 text-xs text-gray-500
28+
shadow-sm dark:border-gray-600
29+
dark:bg-gray-700 dark:text-gray-400
30+
"
31+
>
32+
{tag.name}
33+
</span>
34+
))}
35+
36+
{hiddenCount > 0 ? (
37+
<span
38+
className="
39+
flex h-6
40+
items-center gap-2 rounded-md border
41+
border-gray-300 bg-gray-50 px-2 text-xs text-gray-500
42+
shadow-sm dark:border-gray-600
43+
dark:bg-gray-700 dark:text-gray-400
44+
"
45+
>
46+
+{hiddenCount} more
47+
</span>
48+
) : minimal && !tags.length ? (
49+
<span
50+
className="
51+
flex h-6
52+
items-center gap-2 rounded-md border
53+
border-gray-300 bg-gray-50 px-2 text-xs text-gray-500
54+
opacity-0 shadow-sm
55+
dark:border-gray-600 dark:bg-gray-700
56+
dark:text-gray-400
57+
"
58+
>
59+
placeholder
60+
</span>
61+
) : null}
62+
</div>
63+
);
64+
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import type { PostType } from "../_services/query";
1717
import PostUpvote from "../_components/PostUpvote";
1818
import { getUserRole } from "../_services/action";
1919
import PostCommentCount from "./PostCommentCount";
20+
import PostTags from "./PostTags";
2021
import PostViewCommentList from "./PostViewCommentList";
2122
import PostViewCreateComment from "./PostViewCreateComment";
2223

@@ -105,6 +106,8 @@ export default function PostView({ postId }: PostViewProps) {
105106
<PostCommentCount post={post} />
106107
</div>
107108

109+
<PostTags post={post} />
110+
108111
<div className="max-h-[90vh] overflow-y-auto">
109112
<div className="prose prose-sm dark:prose-invert max-w-none text-sm">
110113
<ReactMarkdown remarkPlugins={[remarkGfm]}>
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"use client";
2+
3+
import { useEffect, useState } from "react";
4+
import { Button } from "@/components/ui/button";
5+
6+
import { Checkbox } from "@codevs/ui/checkbox";
7+
8+
import { getPostTagsLookup } from "../_services/query";
9+
10+
interface TagSelectorProps {
11+
selectedTags?: number[];
12+
onChange?: (selectedTagIds: number[]) => void;
13+
}
14+
15+
interface Tag {
16+
id: number;
17+
name: string;
18+
selected: boolean;
19+
}
20+
21+
const TagSelector = ({ selectedTags = [], onChange }: TagSelectorProps) => {
22+
const [tags, setTags] = useState<Tag[]>([]);
23+
const [isOpen, setIsOpen] = useState(false);
24+
25+
useEffect(() => {
26+
const fetchTags = async () => {
27+
try {
28+
const data = await getPostTagsLookup();
29+
if (!data) return;
30+
31+
const mappedTags: Tag[] = data.map((t) => ({
32+
id: t.id,
33+
name: t.name,
34+
selected: selectedTags.includes(t.id),
35+
}));
36+
37+
setTags(mappedTags);
38+
} catch (error) {
39+
console.error("Failed to fetch tags:", error);
40+
}
41+
};
42+
43+
fetchTags();
44+
}, []);
45+
46+
const toggleTag = (id: number) => {
47+
const updatedTags = tags.map((t) =>
48+
t.id === id ? { ...t, selected: !t.selected } : t,
49+
);
50+
setTags(updatedTags);
51+
52+
if (onChange) {
53+
const selectedIds = updatedTags
54+
.filter((t) => t.selected)
55+
.map((t) => t.id);
56+
onChange(selectedIds);
57+
}
58+
};
59+
60+
return (
61+
<div className="mt-4">
62+
<Button
63+
type="button"
64+
variant="outline"
65+
onClick={() => setIsOpen((prev) => !prev)}
66+
>
67+
{isOpen ? "Hide Tags" : "Select Tags"}
68+
</Button>
69+
70+
{isOpen && (
71+
<div className="mt-2 flex flex-wrap gap-2">
72+
{tags.map((tag) => (
73+
<label key={tag.id} className="flex items-center gap-1">
74+
<Checkbox
75+
checked={tag.selected}
76+
onCheckedChange={() => toggleTag(tag.id)}
77+
/>
78+
<span className="text-sm">{tag.name}</span>
79+
</label>
80+
))}
81+
</div>
82+
)}
83+
</div>
84+
);
85+
};
86+
87+
export default TagSelector;

0 commit comments

Comments
 (0)