From 43adb81590057f5d16d876b6292936cefd05726e Mon Sep 17 00:00:00 2001 From: Shoaib Sharif Date: Sun, 22 Jun 2025 01:28:58 -0500 Subject: [PATCH 1/3] feat: enhance article management and UI components - Added backend functionality to retrieve paginated articles by tag, including input validation and SQL query execution. - Updated the ArticleCard component to improve user interaction with hover effects and user information display. - Enhanced the HomeLeftSidebar with additional programming language tags and corresponding links. - Modified development workflow instructions to reflect the use of 'bun' for database operations. - Introduced new input schema for tag-based article feeds in the article input validation. --- CLAUDE.md | 13 +- .../(home)/_components/HomeLeftSidebar.tsx | 31 +++- .../[tag_id]/_components/TagArticleFeed.tsx | 137 ++++++++++++++++++ src/app/tags/[tag_id]/page.tsx | 41 ++++++ src/backend/services/article.actions.ts | 91 +++++++++++- src/backend/services/inputs/article.input.ts | 6 + src/components/ArticleCard.tsx | 48 +++--- src/components/Navbar/Navbar.tsx | 4 +- task.md | 1 + 9 files changed, 341 insertions(+), 31 deletions(-) create mode 100644 src/app/tags/[tag_id]/_components/TagArticleFeed.tsx create mode 100644 src/app/tags/[tag_id]/page.tsx create mode 100644 task.md 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..7815c99 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/react", }, { 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..fc75675 --- /dev/null +++ b/src/app/tags/[tag_id]/_components/TagArticleFeed.tsx @@ -0,0 +1,137 @@ +"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) => ( + + ))} + +
+ { + console.log(`fetching next page for tag: ${tagId}`); + 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 (
diff --git a/task.md b/task.md new file mode 100644 index 0000000..7b42a46 --- /dev/null +++ b/task.md @@ -0,0 +1 @@ +I want to create a page where it will show articles by tag. that page will be at `/tags/{tag_slug}`. In example: if a user go `/tags/js`, it will show all the articles for that tag. From f4d7732f526e2328a3575649e6e89494d0b959f0 Mon Sep 17 00:00:00 2001 From: Shoaib Sharif Date: Sun, 22 Jun 2025 01:29:48 -0500 Subject: [PATCH 2/3] chore: remove task.md file for tag-based article page --- task.md | 1 - 1 file changed, 1 deletion(-) delete mode 100644 task.md diff --git a/task.md b/task.md deleted file mode 100644 index 7b42a46..0000000 --- a/task.md +++ /dev/null @@ -1 +0,0 @@ -I want to create a page where it will show articles by tag. that page will be at `/tags/{tag_slug}`. In example: if a user go `/tags/js`, it will show all the articles for that tag. From 97ebd8f4f42db6487403f8c79e50e6dcca0cc5a9 Mon Sep 17 00:00:00 2001 From: Shoaib Sharif Date: Sun, 22 Jun 2025 01:51:42 -0500 Subject: [PATCH 3/3] fix: update tag link and improve image handling in TagArticleFeed component --- src/app/(home)/_components/HomeLeftSidebar.tsx | 2 +- src/app/tags/[tag_id]/_components/TagArticleFeed.tsx | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/app/(home)/_components/HomeLeftSidebar.tsx b/src/app/(home)/_components/HomeLeftSidebar.tsx index 7815c99..32aaea0 100644 --- a/src/app/(home)/_components/HomeLeftSidebar.tsx +++ b/src/app/(home)/_components/HomeLeftSidebar.tsx @@ -96,7 +96,7 @@ const tags = [ { icon: "https://res.cloudinary.com/techdiary-dev/image/upload/v1620782239/static-assets/tag-icons/erfbu54l2mquphszheck.svg", label: "react", - link: "/tags/react", + link: "/tags/186e052a-9c5b-4ffe-b753-ea172ac2e663", }, { icon: "https://res.cloudinary.com/techdiary-dev/image/upload/v1620782240/static-assets/tag-icons/rh7xfiz28bxklfzymftd.svg", diff --git a/src/app/tags/[tag_id]/_components/TagArticleFeed.tsx b/src/app/tags/[tag_id]/_components/TagArticleFeed.tsx index fc75675..7dea926 100644 --- a/src/app/tags/[tag_id]/_components/TagArticleFeed.tsx +++ b/src/app/tags/[tag_id]/_components/TagArticleFeed.tsx @@ -106,12 +106,12 @@ const TagArticleFeed: React.FC = ({ tagId }) => { handle={article?.handle ?? ""} title={article?.title ?? ""} excerpt={article?.excerpt ?? ""} - coverImage={getFileUrl(article?.cover_image!)} + coverImage={article?.cover_image ? getFileUrl(article.cover_image) : ""} author={{ id: article?.user?.id ?? "", name: article?.user?.name ?? "", avatar: article?.user?.profile_photo - ? getFileUrl(article?.user?.profile_photo!) + ? getFileUrl(article.user.profile_photo) : "", username: article?.user?.username ?? "", }} @@ -124,7 +124,6 @@ const TagArticleFeed: React.FC = ({ tagId }) => { { - console.log(`fetching next page for tag: ${tagId}`); await tagFeedQuery.fetchNextPage(); }} />