Skip to content

Commit cef9822

Browse files
authored
Merge pull request #63 from techdiary-dev/shoaibsharif/tags
Article Tag
2 parents 9305b88 + 2eaffc1 commit cef9822

File tree

8 files changed

+339
-31
lines changed

8 files changed

+339
-31
lines changed

CLAUDE.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
3333
- **File Storage**: Cloudinary
3434
- **State Management**: Jotai, React Hook Form with Zod validation
3535

36+
### Backend & Database
37+
- **[SQLKit](https://github.com/sqlkit-dev/sqlkit)** - Very light sql query builder, we are using most of the sql query using this.
38+
- **[Drizzle ORM](https://orm.drizzle.team/)** - Awesome sql tool but we are only using for migration
39+
- **[PostgreSQL](https://www.postgresql.org/)** - Primary database
40+
- **[Next.js API Routes](https://nextjs.org/docs/api-routes/introduction)** - Backend API
41+
3642
### Core Directory Structure
3743

3844
#### Frontend (`/src/app/`)
@@ -119,11 +125,14 @@ Client-side:
119125

120126
## Development Workflow
121127

122-
1. Database changes require running `npm run db:generate` followed by `npm run db:push`
123-
2. Backend logic testing can be done via `npm run play` playground script
128+
1. Database changes require running `bun run db:generate` followed by `bun run db:push`
129+
2. Backend logic testing can be done via `bun run play` playground script
124130
3. Type safety is enforced through Zod schemas for all inputs
125131
4. UI components follow shadcn/ui patterns and conventions
126132
5. All forms use React Hook Form with Zod validation schemas
133+
6. When querying data in component always use Tanstack Query.
134+
7. When interacting with DB, create a action in `src/backend/services` and use sqlkit package (https://github.com/sqlkit-dev/sqlkit)
135+
8. For Database schema reference look here for drizzle schema `src/backend/persistence/schemas.ts`
127136

128137
## Special Considerations
129138

src/app/(home)/_components/HomeLeftSidebar.tsx

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,94 +31,117 @@ const tags = [
3131
{
3232
icon: "https://res.cloudinary.com/techdiary-dev/image/upload/v1620782237/static-assets/tag-icons/nbws9ynczmavj86ontfz.svg",
3333
label: "nodejs",
34+
link: "/tags/nodejs",
3435
},
3536
{
3637
icon: "https://res.cloudinary.com/techdiary-dev/image/upload/v1620782240/static-assets/tag-icons/kvyqqabeipmca7utxf8e.svg",
3738
label: "ts",
39+
link: "/tags/typescript",
3840
},
3941
{
4042
icon: "https://res.cloudinary.com/techdiary-dev/image/upload/v1620782237/static-assets/tag-icons/na8zbg5d1tuxt5yp6kay.svg",
4143
label: "js",
44+
link: "/tags/javascript",
4245
},
4346
{
4447
icon: "https://res.cloudinary.com/techdiary-dev/image/upload/v1620782235/static-assets/tag-icons/tritkwhlognysckvztmw.svg",
4548
label: "java",
49+
link: "/tags/java",
4650
},
4751
{
4852
icon: "https://res.cloudinary.com/techdiary-dev/image/upload/v1620782237/static-assets/tag-icons/w9zqspigpdgdmglbjo1g.svg",
4953
label: "python",
54+
link: "/tags/python",
5055
},
5156
{
5257
icon: "https://res.cloudinary.com/techdiary-dev/image/upload/v1620782233/static-assets/tag-icons/zydwlue3nnnbeyyl8pum.svg",
5358
label: "dart",
59+
link: "/tags/dart",
5460
},
5561
{
5662
icon: "https://res.cloudinary.com/techdiary-dev/image/upload/v1620782235/static-assets/tag-icons/qcbazadpuxskoaacu6mn.svg",
5763
label: "go",
64+
link: "/tags/go",
5865
},
5966
{
6067
icon: "https://res.cloudinary.com/techdiary-dev/image/upload/v1620782237/static-assets/tag-icons/akx8gxzfgqdyvcffpadi.svg",
6168
label: "php",
69+
link: "/tags/php",
6270
},
6371
{
6472
icon: "https://res.cloudinary.com/techdiary-dev/image/upload/v1620782239/static-assets/tag-icons/uruwktd4r0g7chwf7f3g.svg",
6573
label: "ruby",
74+
link: "/tags/ruby",
6675
},
6776
{
6877
icon: "https://res.cloudinary.com/techdiary-dev/image/upload/v1620782235/static-assets/tag-icons/ivz6wh9hmtynuug99gcl.svg",
6978
label: "html",
79+
link: "/tags/html",
7080
},
7181
{
7282
icon: "https://res.cloudinary.com/techdiary-dev/image/upload/v1620782233/static-assets/tag-icons/qap8jcvbl5dvjbktnnxo.svg",
7383
label: "css",
84+
link: "/tags/css",
7485
},
7586
{
7687
icon: "https://res.cloudinary.com/techdiary-dev/image/upload/v1620782236/static-assets/tag-icons/hcddvgvejmha0hr8emkz.svg",
7788
label: "laravel",
89+
link: "/tags/laravel",
7890
},
7991
{
8092
icon: "https://res.cloudinary.com/techdiary-dev/image/upload/v1620782235/static-assets/tag-icons/wtxrrpsfqguomzqjavam.svg",
8193
label: "graphql",
94+
link: "/tags/graphql",
8295
},
8396
{
8497
icon: "https://res.cloudinary.com/techdiary-dev/image/upload/v1620782239/static-assets/tag-icons/erfbu54l2mquphszheck.svg",
8598
label: "react",
99+
link: "/tags/186e052a-9c5b-4ffe-b753-ea172ac2e663",
86100
},
87101
{
88102
icon: "https://res.cloudinary.com/techdiary-dev/image/upload/v1620782240/static-assets/tag-icons/rh7xfiz28bxklfzymftd.svg",
89103
label: "vue",
104+
link: "/tags/vue",
90105
},
91106
{
92107
icon: "https://res.cloudinary.com/techdiary-dev/image/upload/v1620782240/static-assets/tag-icons/wsunggfipja7edqsybg5.svg",
93108
label: "svelte",
109+
link: "/tags/svelte",
94110
},
95111
{
96112
icon: "https://res.cloudinary.com/techdiary-dev/image/upload/v1620782232/static-assets/tag-icons/xbazdwl9wpdqi1naqtib.svg",
97113
label: "angular",
114+
link: "/tags/angular",
98115
},
99116
{
100117
icon: "https://res.cloudinary.com/techdiary-dev/image/upload/v1620782233/static-assets/tag-icons/jb9r6xjy7yi1gkeqqvnh.svg",
101118
label: "flutter",
119+
link: "/tags/flutter",
102120
},
103121
{
104122
icon: "https://res.cloudinary.com/techdiary-dev/image/upload/v1620782237/static-assets/tag-icons/odut7ffl8spzdkbhceeu.svg",
105123
label: "kubernetes",
124+
link: "/tags/kubernetes",
106125
},
107126
{
108127
icon: "https://res.cloudinary.com/techdiary-dev/image/upload/v1620782233/static-assets/tag-icons/kow5csrider7v1eizt1q.svg",
109128
label: "docker",
129+
link: "/tags/docker",
110130
},
111131
{
112132
icon: "https://res.cloudinary.com/techdiary-dev/image/upload/v1620782233/static-assets/tag-icons/jkepqds7ziutsnle1e4a.svg",
113133
label: "aws",
134+
link: "/tags/aws",
114135
},
115136
{
116137
icon: "https://res.cloudinary.com/techdiary-dev/image/upload/v1620782234/static-assets/tag-icons/vvx5vhoos8jgkutp48ll.svg",
117138
label: "git",
139+
link: "/tags/git",
118140
},
119141
{
120142
icon: "https://res.cloudinary.com/techdiary-dev/image/upload/v1620782234/static-assets/tag-icons/hwsbp19pfifwr367xtoh.svg",
121143
label: "github",
144+
link: "/tags/github",
122145
},
123146
];
124147

@@ -156,7 +179,11 @@ const Sidebar = () => {
156179

157180
<div className="flex flex-col gap-2">
158181
{tags.slice(0, count).map((tag, index) => (
159-
<div className="flex items-center gap-2" key={index}>
182+
<Link
183+
href={tag.link}
184+
className="flex items-center gap-2"
185+
key={index}
186+
>
160187
<Image
161188
src={tag.icon}
162189
width={20}
@@ -165,7 +192,7 @@ const Sidebar = () => {
165192
alt={tag?.label}
166193
/>
167194
<p className="text-forground-muted">{tag?.label}</p>
168-
</div>
195+
</Link>
169196
))}
170197
</div>
171198

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
"use client";
2+
3+
import * as articleActions from "@/backend/services/article.actions";
4+
import ArticleCard from "@/components/ArticleCard";
5+
import VisibilitySensor from "@/components/VisibilitySensor";
6+
import { readingTime } from "@/lib/utils";
7+
import getFileUrl from "@/utils/getFileUrl";
8+
import { useInfiniteQuery } from "@tanstack/react-query";
9+
import { useMemo } from "react";
10+
11+
interface TagArticleFeedProps {
12+
tagId: string;
13+
}
14+
15+
const TagArticleFeed: React.FC<TagArticleFeedProps> = ({ tagId }) => {
16+
const tagFeedQuery = useInfiniteQuery({
17+
queryKey: ["tag-articles", tagId],
18+
queryFn: ({ pageParam }) =>
19+
articleActions.articlesByTag({
20+
tag_id: tagId,
21+
limit: 5,
22+
page: pageParam,
23+
}),
24+
initialPageParam: 1,
25+
getNextPageParam: (lastPage) => {
26+
if (!lastPage?.meta?.hasNextPage) return undefined;
27+
const _page = lastPage?.meta?.currentPage ?? 1;
28+
return _page + 1;
29+
},
30+
});
31+
32+
const feedArticles = useMemo(() => {
33+
return tagFeedQuery.data?.pages.flatMap((page) => page?.nodes) ?? [];
34+
}, [tagFeedQuery.data]);
35+
36+
const totalArticles = useMemo(() => {
37+
return tagFeedQuery.data?.pages?.[0]?.meta?.total ?? 0;
38+
}, [tagFeedQuery.data]);
39+
40+
const tagName = useMemo(() => {
41+
return tagFeedQuery.data?.pages?.[0]?.tagName ?? "Unknown Tag";
42+
}, [tagFeedQuery.data]);
43+
44+
// Show loading skeletons
45+
if (tagFeedQuery.isPending) {
46+
return (
47+
<div className="flex flex-col gap-10 mt-2">
48+
<div className="h-56 bg-muted animate-pulse mx-4" />
49+
<div className="h-56 bg-muted animate-pulse mx-4" />
50+
<div className="h-56 bg-muted animate-pulse mx-4" />
51+
<div className="h-56 bg-muted animate-pulse mx-4" />
52+
</div>
53+
);
54+
}
55+
56+
// Show error state
57+
if (tagFeedQuery.isError) {
58+
return (
59+
<div className="flex flex-col items-center justify-center py-12">
60+
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
61+
Error loading articles
62+
</h2>
63+
<p className="text-gray-600 dark:text-gray-400 mb-4">
64+
Failed to load articles for this tag.
65+
</p>
66+
<button
67+
onClick={() => tagFeedQuery.refetch()}
68+
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
69+
>
70+
Try again
71+
</button>
72+
</div>
73+
);
74+
}
75+
76+
// Show empty state
77+
if (feedArticles.length === 0) {
78+
return (
79+
<div className="flex flex-col items-center justify-center py-12">
80+
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
81+
No articles found
82+
</h2>
83+
<p className="text-gray-600 dark:text-gray-400">
84+
No articles have been tagged with &ldquo;{tagName}&rdquo; yet.
85+
</p>
86+
</div>
87+
);
88+
}
89+
90+
return (
91+
<>
92+
<div className="mb-8">
93+
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
94+
Articles tagged with &ldquo;{tagName}&rdquo;
95+
</h1>
96+
<p className="text-gray-600 dark:text-gray-400 mt-2">
97+
Found {totalArticles} articles
98+
</p>
99+
</div>
100+
101+
<div className="flex flex-col gap-10 mt-2">
102+
{feedArticles.map((article) => (
103+
<ArticleCard
104+
key={article?.id}
105+
id={article?.id?.toString() ?? ""}
106+
handle={article?.handle ?? ""}
107+
title={article?.title ?? ""}
108+
excerpt={article?.excerpt ?? ""}
109+
coverImage={article?.cover_image ? getFileUrl(article.cover_image) : ""}
110+
author={{
111+
id: article?.user?.id ?? "",
112+
name: article?.user?.name ?? "",
113+
avatar: article?.user?.profile_photo
114+
? getFileUrl(article.user.profile_photo)
115+
: "",
116+
username: article?.user?.username ?? "",
117+
}}
118+
publishedAt={article?.created_at?.toDateString() ?? ""}
119+
readingTime={readingTime(article?.body ?? "")}
120+
/>
121+
))}
122+
123+
<div className="my-10">
124+
<VisibilitySensor
125+
visible={tagFeedQuery.hasNextPage}
126+
onLoadmore={async () => {
127+
await tagFeedQuery.fetchNextPage();
128+
}}
129+
/>
130+
</div>
131+
</div>
132+
</>
133+
);
134+
};
135+
136+
export default TagArticleFeed;

src/app/tags/[tag_id]/page.tsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import HomeLeftSidebar from "@/app/(home)/_components/HomeLeftSidebar";
2+
import HomeRightSidebar from "@/app/(home)/_components/HomeRightSidebar";
3+
import SidebarToggleButton from "@/app/(home)/_components/SidebarToggleButton";
4+
import HomepageLayout from "@/components/layout/HomepageLayout";
5+
import TagArticleFeed from "./_components/TagArticleFeed";
6+
7+
interface TagPageProps {
8+
params: Promise<{
9+
tag_id: string;
10+
}>;
11+
}
12+
13+
export default async function TagPage({ params }: TagPageProps) {
14+
const { tag_id } = await params;
15+
16+
return (
17+
<HomepageLayout
18+
LeftSidebar={<HomeLeftSidebar />}
19+
RightSidebar={<HomeRightSidebar />}
20+
NavbarTrailing={<SidebarToggleButton />}
21+
>
22+
<div className="px-4 py-6">
23+
<TagArticleFeed tagId={tag_id} />
24+
</div>
25+
</HomepageLayout>
26+
);
27+
}
28+
29+
export async function generateMetadata({ params }: TagPageProps) {
30+
const { tag_id } = await params;
31+
32+
// For now, use tag_id in the title. Later we can fetch the tag name if needed
33+
return {
34+
title: `Tag ${tag_id} - Tech Diary`,
35+
description: `Browse all articles with this tag on Tech Diary`,
36+
openGraph: {
37+
title: `Tag ${tag_id} - Tech Diary`,
38+
description: `Browse all articles with this tag on Tech Diary`,
39+
},
40+
};
41+
}

0 commit comments

Comments
 (0)