Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`)
Expand Down Expand Up @@ -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

Expand Down
31 changes: 29 additions & 2 deletions src/app/(home)/_components/HomeLeftSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
];

Expand Down Expand Up @@ -156,7 +179,11 @@ const Sidebar = () => {

<div className="flex flex-col gap-2">
{tags.slice(0, count).map((tag, index) => (
<div className="flex items-center gap-2" key={index}>
<Link
href={tag.link}
className="flex items-center gap-2"
key={index}
>
<Image
src={tag.icon}
width={20}
Expand All @@ -165,7 +192,7 @@ const Sidebar = () => {
alt={tag?.label}
/>
<p className="text-forground-muted">{tag?.label}</p>
</div>
</Link>
))}
</div>

Expand Down
136 changes: 136 additions & 0 deletions src/app/tags/[tag_id]/_components/TagArticleFeed.tsx
Original file line number Diff line number Diff line change
@@ -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<TagArticleFeedProps> = ({ 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 (
<div className="flex flex-col gap-10 mt-2">
<div className="h-56 bg-muted animate-pulse mx-4" />
<div className="h-56 bg-muted animate-pulse mx-4" />
<div className="h-56 bg-muted animate-pulse mx-4" />
<div className="h-56 bg-muted animate-pulse mx-4" />
</div>
);
}

// Show error state
if (tagFeedQuery.isError) {
return (
<div className="flex flex-col items-center justify-center py-12">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
Error loading articles
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-4">
Failed to load articles for this tag.
</p>
<button
onClick={() => tagFeedQuery.refetch()}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
>
Try again
</button>
</div>
);
}

// Show empty state
if (feedArticles.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-12">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
No articles found
</h2>
<p className="text-gray-600 dark:text-gray-400">
No articles have been tagged with &ldquo;{tagName}&rdquo; yet.
</p>
</div>
);
}

return (
<>
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
Articles tagged with &ldquo;{tagName}&rdquo;
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-2">
Found {totalArticles} articles
</p>
</div>

<div className="flex flex-col gap-10 mt-2">
{feedArticles.map((article) => (
<ArticleCard
key={article?.id}
id={article?.id?.toString() ?? ""}
handle={article?.handle ?? ""}
title={article?.title ?? ""}
excerpt={article?.excerpt ?? ""}
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)
: "",
username: article?.user?.username ?? "",
}}
publishedAt={article?.created_at?.toDateString() ?? ""}
readingTime={readingTime(article?.body ?? "")}
/>
))}

<div className="my-10">
<VisibilitySensor
visible={tagFeedQuery.hasNextPage}
onLoadmore={async () => {
await tagFeedQuery.fetchNextPage();
}}
/>
</div>
</div>
</>
);
};

export default TagArticleFeed;
41 changes: 41 additions & 0 deletions src/app/tags/[tag_id]/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<HomepageLayout
LeftSidebar={<HomeLeftSidebar />}
RightSidebar={<HomeRightSidebar />}
NavbarTrailing={<SidebarToggleButton />}
>
<div className="px-4 py-6">
<TagArticleFeed tagId={tag_id} />
</div>
</HomepageLayout>
);
}

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`,
},
};
}
Loading