diff --git a/CLAUDE.md b/CLAUDE.md
index 0f6a6e3..a2d45d8 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -33,6 +33,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- **File Storage**: Cloudinary
- **State Management**: Jotai, React Hook Form with Zod validation
+### Backend & Database
+- **[SQLKit](https://github.com/sqlkit-dev/sqlkit)** - Very light sql query builder, we are using most of the sql query using this.
+- **[Drizzle ORM](https://orm.drizzle.team/)** - Awesome sql tool but we are only using for migration
+- **[PostgreSQL](https://www.postgresql.org/)** - Primary database
+- **[Next.js API Routes](https://nextjs.org/docs/api-routes/introduction)** - Backend API
+
### Core Directory Structure
#### Frontend (`/src/app/`)
@@ -119,11 +125,14 @@ Client-side:
## Development Workflow
-1. Database changes require running `npm run db:generate` followed by `npm run db:push`
-2. Backend logic testing can be done via `npm run play` playground script
+1. Database changes require running `bun run db:generate` followed by `bun run db:push`
+2. Backend logic testing can be done via `bun run play` playground script
3. Type safety is enforced through Zod schemas for all inputs
4. UI components follow shadcn/ui patterns and conventions
5. All forms use React Hook Form with Zod validation schemas
+6. When querying data in component always use Tanstack Query.
+7. When interacting with DB, create a action in `src/backend/services` and use sqlkit package (https://github.com/sqlkit-dev/sqlkit)
+8. For Database schema reference look here for drizzle schema `src/backend/persistence/schemas.ts`
## Special Considerations
diff --git a/src/app/(home)/_components/HomeLeftSidebar.tsx b/src/app/(home)/_components/HomeLeftSidebar.tsx
index 59404f6..32aaea0 100644
--- a/src/app/(home)/_components/HomeLeftSidebar.tsx
+++ b/src/app/(home)/_components/HomeLeftSidebar.tsx
@@ -31,94 +31,117 @@ const tags = [
{
icon: "https://res.cloudinary.com/techdiary-dev/image/upload/v1620782237/static-assets/tag-icons/nbws9ynczmavj86ontfz.svg",
label: "nodejs",
+ link: "/tags/nodejs",
},
{
icon: "https://res.cloudinary.com/techdiary-dev/image/upload/v1620782240/static-assets/tag-icons/kvyqqabeipmca7utxf8e.svg",
label: "ts",
+ link: "/tags/typescript",
},
{
icon: "https://res.cloudinary.com/techdiary-dev/image/upload/v1620782237/static-assets/tag-icons/na8zbg5d1tuxt5yp6kay.svg",
label: "js",
+ link: "/tags/javascript",
},
{
icon: "https://res.cloudinary.com/techdiary-dev/image/upload/v1620782235/static-assets/tag-icons/tritkwhlognysckvztmw.svg",
label: "java",
+ link: "/tags/java",
},
{
icon: "https://res.cloudinary.com/techdiary-dev/image/upload/v1620782237/static-assets/tag-icons/w9zqspigpdgdmglbjo1g.svg",
label: "python",
+ link: "/tags/python",
},
{
icon: "https://res.cloudinary.com/techdiary-dev/image/upload/v1620782233/static-assets/tag-icons/zydwlue3nnnbeyyl8pum.svg",
label: "dart",
+ link: "/tags/dart",
},
{
icon: "https://res.cloudinary.com/techdiary-dev/image/upload/v1620782235/static-assets/tag-icons/qcbazadpuxskoaacu6mn.svg",
label: "go",
+ link: "/tags/go",
},
{
icon: "https://res.cloudinary.com/techdiary-dev/image/upload/v1620782237/static-assets/tag-icons/akx8gxzfgqdyvcffpadi.svg",
label: "php",
+ link: "/tags/php",
},
{
icon: "https://res.cloudinary.com/techdiary-dev/image/upload/v1620782239/static-assets/tag-icons/uruwktd4r0g7chwf7f3g.svg",
label: "ruby",
+ link: "/tags/ruby",
},
{
icon: "https://res.cloudinary.com/techdiary-dev/image/upload/v1620782235/static-assets/tag-icons/ivz6wh9hmtynuug99gcl.svg",
label: "html",
+ link: "/tags/html",
},
{
icon: "https://res.cloudinary.com/techdiary-dev/image/upload/v1620782233/static-assets/tag-icons/qap8jcvbl5dvjbktnnxo.svg",
label: "css",
+ link: "/tags/css",
},
{
icon: "https://res.cloudinary.com/techdiary-dev/image/upload/v1620782236/static-assets/tag-icons/hcddvgvejmha0hr8emkz.svg",
label: "laravel",
+ link: "/tags/laravel",
},
{
icon: "https://res.cloudinary.com/techdiary-dev/image/upload/v1620782235/static-assets/tag-icons/wtxrrpsfqguomzqjavam.svg",
label: "graphql",
+ link: "/tags/graphql",
},
{
icon: "https://res.cloudinary.com/techdiary-dev/image/upload/v1620782239/static-assets/tag-icons/erfbu54l2mquphszheck.svg",
label: "react",
+ link: "/tags/186e052a-9c5b-4ffe-b753-ea172ac2e663",
},
{
icon: "https://res.cloudinary.com/techdiary-dev/image/upload/v1620782240/static-assets/tag-icons/rh7xfiz28bxklfzymftd.svg",
label: "vue",
+ link: "/tags/vue",
},
{
icon: "https://res.cloudinary.com/techdiary-dev/image/upload/v1620782240/static-assets/tag-icons/wsunggfipja7edqsybg5.svg",
label: "svelte",
+ link: "/tags/svelte",
},
{
icon: "https://res.cloudinary.com/techdiary-dev/image/upload/v1620782232/static-assets/tag-icons/xbazdwl9wpdqi1naqtib.svg",
label: "angular",
+ link: "/tags/angular",
},
{
icon: "https://res.cloudinary.com/techdiary-dev/image/upload/v1620782233/static-assets/tag-icons/jb9r6xjy7yi1gkeqqvnh.svg",
label: "flutter",
+ link: "/tags/flutter",
},
{
icon: "https://res.cloudinary.com/techdiary-dev/image/upload/v1620782237/static-assets/tag-icons/odut7ffl8spzdkbhceeu.svg",
label: "kubernetes",
+ link: "/tags/kubernetes",
},
{
icon: "https://res.cloudinary.com/techdiary-dev/image/upload/v1620782233/static-assets/tag-icons/kow5csrider7v1eizt1q.svg",
label: "docker",
+ link: "/tags/docker",
},
{
icon: "https://res.cloudinary.com/techdiary-dev/image/upload/v1620782233/static-assets/tag-icons/jkepqds7ziutsnle1e4a.svg",
label: "aws",
+ link: "/tags/aws",
},
{
icon: "https://res.cloudinary.com/techdiary-dev/image/upload/v1620782234/static-assets/tag-icons/vvx5vhoos8jgkutp48ll.svg",
label: "git",
+ link: "/tags/git",
},
{
icon: "https://res.cloudinary.com/techdiary-dev/image/upload/v1620782234/static-assets/tag-icons/hwsbp19pfifwr367xtoh.svg",
label: "github",
+ link: "/tags/github",
},
];
@@ -156,7 +179,11 @@ const Sidebar = () => {
{tags.slice(0, count).map((tag, index) => (
-
+
{
alt={tag?.label}
/>
{tag?.label}
-
+
))}
diff --git a/src/app/tags/[tag_id]/_components/TagArticleFeed.tsx b/src/app/tags/[tag_id]/_components/TagArticleFeed.tsx
new file mode 100644
index 0000000..7dea926
--- /dev/null
+++ b/src/app/tags/[tag_id]/_components/TagArticleFeed.tsx
@@ -0,0 +1,136 @@
+"use client";
+
+import * as articleActions from "@/backend/services/article.actions";
+import ArticleCard from "@/components/ArticleCard";
+import VisibilitySensor from "@/components/VisibilitySensor";
+import { readingTime } from "@/lib/utils";
+import getFileUrl from "@/utils/getFileUrl";
+import { useInfiniteQuery } from "@tanstack/react-query";
+import { useMemo } from "react";
+
+interface TagArticleFeedProps {
+ tagId: string;
+}
+
+const TagArticleFeed: React.FC = ({ tagId }) => {
+ const tagFeedQuery = useInfiniteQuery({
+ queryKey: ["tag-articles", tagId],
+ queryFn: ({ pageParam }) =>
+ articleActions.articlesByTag({
+ tag_id: tagId,
+ limit: 5,
+ page: pageParam,
+ }),
+ initialPageParam: 1,
+ getNextPageParam: (lastPage) => {
+ if (!lastPage?.meta?.hasNextPage) return undefined;
+ const _page = lastPage?.meta?.currentPage ?? 1;
+ return _page + 1;
+ },
+ });
+
+ const feedArticles = useMemo(() => {
+ return tagFeedQuery.data?.pages.flatMap((page) => page?.nodes) ?? [];
+ }, [tagFeedQuery.data]);
+
+ const totalArticles = useMemo(() => {
+ return tagFeedQuery.data?.pages?.[0]?.meta?.total ?? 0;
+ }, [tagFeedQuery.data]);
+
+ const tagName = useMemo(() => {
+ return tagFeedQuery.data?.pages?.[0]?.tagName ?? "Unknown Tag";
+ }, [tagFeedQuery.data]);
+
+ // Show loading skeletons
+ if (tagFeedQuery.isPending) {
+ return (
+
+ );
+ }
+
+ // Show error state
+ if (tagFeedQuery.isError) {
+ return (
+
+
+ Error loading articles
+
+
+ Failed to load articles for this tag.
+
+
+
+ );
+ }
+
+ // Show empty state
+ if (feedArticles.length === 0) {
+ return (
+
+
+ No articles found
+
+
+ No articles have been tagged with “{tagName}” yet.
+
+
+ );
+ }
+
+ return (
+ <>
+
+
+ Articles tagged with “{tagName}”
+
+
+ Found {totalArticles} articles
+
+
+
+
+ {feedArticles.map((article) => (
+
+ ))}
+
+
+ {
+ await tagFeedQuery.fetchNextPage();
+ }}
+ />
+
+
+ >
+ );
+};
+
+export default TagArticleFeed;
\ No newline at end of file
diff --git a/src/app/tags/[tag_id]/page.tsx b/src/app/tags/[tag_id]/page.tsx
new file mode 100644
index 0000000..a522c84
--- /dev/null
+++ b/src/app/tags/[tag_id]/page.tsx
@@ -0,0 +1,41 @@
+import HomeLeftSidebar from "@/app/(home)/_components/HomeLeftSidebar";
+import HomeRightSidebar from "@/app/(home)/_components/HomeRightSidebar";
+import SidebarToggleButton from "@/app/(home)/_components/SidebarToggleButton";
+import HomepageLayout from "@/components/layout/HomepageLayout";
+import TagArticleFeed from "./_components/TagArticleFeed";
+
+interface TagPageProps {
+ params: Promise<{
+ tag_id: string;
+ }>;
+}
+
+export default async function TagPage({ params }: TagPageProps) {
+ const { tag_id } = await params;
+
+ return (
+ }
+ RightSidebar={}
+ NavbarTrailing={}
+ >
+
+
+
+
+ );
+}
+
+export async function generateMetadata({ params }: TagPageProps) {
+ const { tag_id } = await params;
+
+ // For now, use tag_id in the title. Later we can fetch the tag name if needed
+ return {
+ title: `Tag ${tag_id} - Tech Diary`,
+ description: `Browse all articles with this tag on Tech Diary`,
+ openGraph: {
+ title: `Tag ${tag_id} - Tech Diary`,
+ description: `Browse all articles with this tag on Tech Diary`,
+ },
+ };
+}
\ No newline at end of file
diff --git a/src/backend/services/article.actions.ts b/src/backend/services/article.actions.ts
index 9b7bfdd..ecd57a5 100644
--- a/src/backend/services/article.actions.ts
+++ b/src/backend/services/article.actions.ts
@@ -1,5 +1,6 @@
"use server";
+import { pgClient } from "@/backend/persistence/clients";
import { slugify } from "@/lib/slug-helper.util";
import {
removeMarkdownSyntax,
@@ -13,8 +14,8 @@ import { ActionResponse } from "../models/action-contracts";
import { Article, User } from "../models/domain-models";
import { DatabaseTableName } from "../persistence/persistence-contracts";
import { persistenceRepository } from "../persistence/persistence-repositories";
-import { ActionException, handleActionException } from "./RepositoryException";
import { ArticleRepositoryInput } from "./inputs/article.input";
+import { ActionException, handleActionException } from "./RepositoryException";
import { deleteArticleById, syncArticleById } from "./search.service";
import { authID } from "./session.actions";
import { syncTagsWithArticles } from "./tag.action";
@@ -395,6 +396,94 @@ export async function userArticleFeed(
}
}
+/**
+ * Retrieves a paginated feed of published articles filtered by tag ID.
+ *
+ * @param _input - Feed parameters including tag_id, page and limit, validated against ArticleRepositoryInput.tagFeedInput schema
+ * @returns Promise<{ data: Article[], total: number }> - Paginated articles with total count
+ * @throws {ActionException} If query fails or validation fails
+ */
+export async function articlesByTag(
+ _input: z.infer
+) {
+ try {
+ const input = await ArticleRepositoryInput.tagFeedInput.parseAsync(_input);
+ const offset = (input.page - 1) * input.limit;
+
+ // Single SQL query to get articles by tag with pagination
+ const sql = String.raw;
+ const articlesQuery = sql`
+ SELECT
+ a.id,
+ a.title,
+ a.handle,
+ a.cover_image,
+ a.body,
+ a.created_at,
+ a.excerpt,
+ u.id as user_id,
+ u.name as user_name,
+ u.username as user_username,
+ u.profile_photo as user_profile_photo,
+ t.name as tag_name,
+ COUNT(*) OVER() as total_count
+ FROM articles a
+ INNER JOIN article_tag at ON a.id = at.article_id
+ INNER JOIN tags t ON at.tag_id = t.id
+ LEFT JOIN users u ON a.author_id = u.id
+ WHERE
+ a.is_published = true
+ AND t.id = $1
+ ORDER BY a.published_at DESC
+ LIMIT $2 OFFSET $3
+ `;
+
+ const result = await pgClient?.executeSQL(articlesQuery, [
+ input.tag_id,
+ input.limit,
+ offset,
+ ]);
+
+ const rows = result?.rows || [];
+ const totalCount = rows.length > 0 ? parseInt(rows[0].total_count) : 0;
+ const totalPages = Math.ceil(totalCount / input.limit);
+
+ // Transform the data to match the expected format
+ const nodes = rows.map((row: any) => ({
+ id: row.id,
+ title: row.title,
+ handle: row.handle,
+ cover_image: row.cover_image,
+ body: row.body,
+ created_at: new Date(row.created_at),
+ excerpt: row.excerpt ?? removeMarkdownSyntax(row.body),
+ user: {
+ id: row.user_id,
+ name: row.user_name,
+ username: row.user_username,
+ profile_photo: row.user_profile_photo,
+ },
+ }));
+
+ // Get tag name from first row for display purposes
+ const tagName = rows.length > 0 ? rows[0].tag_name : null;
+
+ return {
+ nodes,
+ tagName,
+ meta: {
+ total: totalCount,
+ currentPage: input.page,
+ totalPages,
+ hasNextPage: input.page < totalPages,
+ hasPreviousPage: input.page > 1,
+ },
+ };
+ } catch (error) {
+ handleActionException(error);
+ }
+}
+
export async function articleDetailByHandle(article_handle: string) {
try {
const [article] = await persistenceRepository.article.find({
diff --git a/src/backend/services/inputs/article.input.ts b/src/backend/services/inputs/article.input.ts
index 4c6bfe0..02430d3 100644
--- a/src/backend/services/inputs/article.input.ts
+++ b/src/backend/services/inputs/article.input.ts
@@ -119,4 +119,10 @@ export const ArticleRepositoryInput = {
page: z.number().default(1),
limit: z.number().default(10),
}),
+
+ tagFeedInput: z.object({
+ tag_id: z.string().uuid(),
+ page: z.number().default(1),
+ limit: z.number().default(10),
+ }),
};
diff --git a/src/components/ArticleCard.tsx b/src/components/ArticleCard.tsx
index c8b7d85..a019b73 100644
--- a/src/components/ArticleCard.tsx
+++ b/src/components/ArticleCard.tsx
@@ -48,10 +48,10 @@ const ArticleCard = ({
return (
-
-
-
-
+
+
+
+
-
-
- {author.name}
-
-
-
- ·
- {readingTime} min read
-
-
+
+
+
+
+
+
+
+ {author.name}
+
+
+
+ ·
+ {readingTime} min read
-
-
-
-
-
+
+
diff --git a/src/components/Navbar/Navbar.tsx b/src/components/Navbar/Navbar.tsx
index 8fc3337..f4a64e2 100644
--- a/src/components/Navbar/Navbar.tsx
+++ b/src/components/Navbar/Navbar.tsx
@@ -1,14 +1,14 @@
import Link from "next/link";
import TechdiaryLogo from "../logos/TechdiaryLogo";
-import SearchInput from "./SearchInput";
import NavbarActions from "./NavbarActions";
+import SearchInput from "./SearchInput";
interface NavbarProps {
Trailing?: React.ReactNode;
}
-const Navbar: React.FC
= async ({ Trailing }) => {
+const Navbar: React.FC = ({ Trailing }) => {
return (