- {tags.slice(0, count).map((tag, index) => (
-
- ))}
+
+
+
+
+
{_t("Home")}
+
+
+
+
{_t("Bookmarks")}
+
+
+
+
{_t("New diary")}
+
- {count === 10 && (
-
- )}
+
+
+ {_t("Top tags")}
+
+
+
+ {tags.slice(0, count).map((tag, index) => (
+
+

+
{tag?.label}
+
+ ))}
+
+
+ {count === 10 && (
+
+ )}
+
);
};
-
-export default TagsWidget;
diff --git a/src/app/(home)/_components/HomeRightSidebar.tsx b/src/app/(home)/_components/HomeRightSidebar.tsx
new file mode 100644
index 0000000..4453bc3
--- /dev/null
+++ b/src/app/(home)/_components/HomeRightSidebar.tsx
@@ -0,0 +1,18 @@
+import DiscordWidget from "@/components/widgets/DiscordWidget";
+import ImportantLinksWidget from "@/components/widgets/ImportantLinksWidget";
+import LatestUsers from "@/components/widgets/LatestUsers";
+import SocialLinksWidget from "@/components/widgets/SocialLinksWidget";
+import React from "react";
+
+const HomeRightSidebar = () => {
+ return (
+
+
+
+
+
+
+ );
+};
+
+export default HomeRightSidebar;
diff --git a/src/app/(home)/_components/SidebarToggleButton.tsx b/src/app/(home)/_components/SidebarToggleButton.tsx
new file mode 100644
index 0000000..f2abcfd
--- /dev/null
+++ b/src/app/(home)/_components/SidebarToggleButton.tsx
@@ -0,0 +1,17 @@
+"use client";
+
+import { homeSidebarOpenAtom } from "@/store/home-sidebar.atom";
+import { useAtom } from "jotai";
+import { MenuIcon } from "lucide-react";
+
+const SidebarToggleButton = () => {
+ const [open, setOpen] = useAtom(homeSidebarOpenAtom);
+
+ return (
+
+ );
+};
+
+export default SidebarToggleButton;
diff --git a/src/app/(home)/components/ArticleCard.tsx b/src/app/(home)/components/ArticleCard.tsx
deleted file mode 100644
index 7d3960e..0000000
--- a/src/app/(home)/components/ArticleCard.tsx
+++ /dev/null
@@ -1,266 +0,0 @@
-"use client";
-
-import { useClipboard, useSetState } from "@mantine/hooks";
-
-import useShare from "@/hooks/useShare";
-import { notifications } from "@mantine/notifications";
-
-import { relativeTime } from "@/utils/relativeTime";
-import { HoverCard, Menu, Text } from "@mantine/core";
-import { useMutation } from "@tanstack/react-query";
-import Link from "next/link";
-import React from "react";
-import { AiFillFacebook } from "react-icons/ai";
-import { FiCopy } from "react-icons/fi";
-import { LiaCommentsSolid } from "react-icons/lia";
-import { RiTwitterFill } from "react-icons/ri";
-
-import useVote from "@/hooks/useVote";
-import classNames from "clsx";
-
-import AppImage from "@/components/AppImage";
-import UserHoverCard from "@/components/UserHoverCard";
-import { IArticleFeedItem } from "@/http/models/Article.model";
-import { BookmarkRepository } from "@/http/repositories/bookmark.repository";
-import { useTranslation } from "@/i18n/use-translation";
-import { userAtom } from "@/store/user.atom";
-import {
- BookmarkFilledIcon,
- BookmarkIcon,
- ChevronDownIcon,
- ChevronUpIcon,
- Share2Icon,
-} from "@radix-ui/react-icons";
-import { useAtomValue } from "jotai";
-
-interface Props {
- article: IArticleFeedItem;
-}
-
-const ArticleCard: React.FC
= ({ article }) => {
- const { _t } = useTranslation();
- const bookmarkRepository = new BookmarkRepository();
- const currentUser = useAtomValue(userAtom);
- const clipboard = useClipboard({ timeout: 100 });
-
- const [state, setState] = useSetState({
- bookmarked_users: article?.bookmarked_users,
- });
-
- const toogleBookmarkState = (bookmarked?: boolean) => {
- if (state.bookmarked_users?.includes(currentUser?.id!)) {
- setState({
- bookmarked_users: state.bookmarked_users?.filter(
- (id) => id !== currentUser?.id!
- ),
- });
- } else {
- setState({
- bookmarked_users: [...state.bookmarked_users, currentUser?.id!],
- });
- }
- };
-
- const { mutate: mutate__createBookmark } = useMutation({
- mutationFn: (id: string) => {
- toogleBookmarkState();
- return bookmarkRepository.createBook({
- model_name: "ARTICLE",
- model_id: id,
- });
- },
- onSuccess: (res) => {
- if (res?.data?.bookmarked) {
- setState({
- bookmarked_users: [...state.bookmarked_users, currentUser?.id!],
- });
- } else {
- setState({
- bookmarked_users: state.bookmarked_users?.filter(
- (id) => id !== currentUser?.id!
- ),
- });
- }
- },
- });
-
- const { share } = useShare(article.url);
- const { voteState } = useVote({
- modelName: "ARTICLE",
- id: article.id,
- data: article.votes,
- });
-
- return (
-
-
- {/* */}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {article?.user?.username}
-
-
-
- {relativeTime(new Date(article?.created_at))}
-
-
-
-
- {/* */}
-
-
- {/* Bookmark button */}
-
-
-
-
-
-
- {article?.title}
-
-
-
- {article?.body?.excerpt}
- {"..."}
-
-
- [āĻĒā§ā§āύ]
-
-
-
- {article?.thumbnail ? (
-
-
-
- ) : null}
-
-
-
-
-
-
-
-
-
-
-
- {article?.comments_count}
-
-
-
-
- );
-};
-
-export default ArticleCard;
diff --git a/src/app/(home)/components/ArticleFeed.tsx b/src/app/(home)/components/ArticleFeed.tsx
deleted file mode 100644
index b2a48b2..0000000
--- a/src/app/(home)/components/ArticleFeed.tsx
+++ /dev/null
@@ -1,61 +0,0 @@
-"use client";
-
-import { IArticleFeedItem } from "@/http/models/Article.model";
-import { PaginatedResponse } from "@/http/models/PaginatedResponse.model";
-import { ArticleApiRepository } from "@/http/repositories/article.repository";
-import { Loader } from "@mantine/core";
-import { useInfiniteQuery } from "@tanstack/react-query";
-import React from "react";
-import { VisibilityObserver } from "reactjs-visibility";
-import ArticleCard from "./ArticleCard";
-
-interface ArticleFeedProps {
- initialData: PaginatedResponse;
-}
-const ArticleFeed: React.FC = ({ initialData }) => {
- const articleRepository = new ArticleApiRepository();
-
- const { data, fetchNextPage } = useInfiniteQuery<
- PaginatedResponse
- >({
- queryKey: ["article-feed"],
- initialData: {
- pageParams: [initialData.meta.current_page],
- pages: [initialData],
- },
- initialPageParam: initialData.meta.current_page,
- refetchOnMount: false,
- getNextPageParam: (lastPage, pages) => lastPage.meta.current_page + 1,
- queryFn: async ({ pageParam }) => {
- return articleRepository.getArticles({
- page: pageParam as number,
- limit: 10,
- });
- },
- });
-
- return (
-
- {data?.pages.map((page) => {
- return page.data.map((article) => (
-
- ));
- })}
-
-
{
- if (isVisible) {
- fetchNextPage();
- }
- }}
- options={{ rootMargin: "200px" }}
- >
-
-
-
-
-
- );
-};
-
-export default ArticleFeed;
diff --git a/src/app/(home)/error.tsx b/src/app/(home)/error.tsx
deleted file mode 100644
index 37945c0..0000000
--- a/src/app/(home)/error.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-"use client";
-import React, { useEffect } from "react";
-
-interface HomePageErrorProps {
- error: Error & { digest?: string };
- reset: () => void;
-}
-
-const HomePageError: React.FC = ({ error, reset }) => {
- useEffect(() => {
- // Log the error to an error reporting service
- console.error(error);
- }, [error]);
-
- return (
-
-
Something went wrong!
-
{JSON.stringify(error, null, 2)}
-
-
- );
-};
-
-export default HomePageError;
diff --git a/src/app/(home)/page.tsx b/src/app/(home)/page.tsx
index 6f8113b..0b1a4f3 100644
--- a/src/app/(home)/page.tsx
+++ b/src/app/(home)/page.tsx
@@ -1,37 +1,23 @@
-import { http } from "@/http/http.client";
-import FakeEditor from "@/components/FakeEditor";
-import HomeLeftSidebar from "@/components/asides/HomeLeftSidebar";
-import HomeRightSidebar from "@/components/asides/HomeRightSidebar";
-import ThreeColumnLayout from "@/components/layout/ThreeColumnLayout";
-import { IArticleFeedItem } from "@/http/models/Article.model";
-import { PaginatedResponse } from "@/http/models/PaginatedResponse.model";
-import ArticleFeed from "./components/ArticleFeed";
-import axios from "axios";
-
-// export const revalidate = 3600; // revalidate at most every hour
-
-export default async function Home() {
- // const { data: articles } = await http.get<
- // PaginatedResponse
- // >("api/articles", { params: { limit: 20 } });
-
- const api = await fetch(process.env.NEXT_PUBLIC_API_URL + "/api/articles", {
- method: "GET",
- cache: "no-store",
- });
-
- const articles = await api.json();
+import HomepageLayout from "@/components/layout/HomepageLayout";
+import ArticleFeed from "./_components/ArticleFeed";
+import FakeEditor from "./_components/FakeEditor";
+import HomeLeftSidebar from "./_components/HomeLeftSidebar";
+import HomeRightSidebar from "./_components/HomeRightSidebar";
+import SidebarToggleButton from "./_components/SidebarToggleButton";
+const Page = () => {
return (
- }
RightSidebar={}
+ NavbarTrailing={}
>
-
-
-
+
+
-
-
+
+
);
-}
+};
+
+export default Page;
diff --git a/src/app/[username]/(profile)/page.tsx b/src/app/[username]/(profile)/page.tsx
deleted file mode 100644
index 321b925..0000000
--- a/src/app/[username]/(profile)/page.tsx
+++ /dev/null
@@ -1,265 +0,0 @@
-import { http } from "@/http/http.client";
-import {
- FaBehance,
- FaDribbble,
- FaFacebook,
- FaGithub,
- FaInstagram,
- FaLinkedinIn,
- FaMedium,
- FaStackOverflow,
- FaTwitch,
- FaTwitter,
- FaYoutube,
-} from "react-icons/fa";
-import BaseLayout from "@/components/layout/BaseLayout";
-import { IUser } from "@/http/models/User.model";
-import { Link2Icon } from "@radix-ui/react-icons";
-import { NextPage } from "next";
-import React from "react";
-import { markdownToHtml } from "@/utils/markdoc-parser";
-
-interface UserProfilePageProps {
- params: { username: string };
-}
-
-const UserProfilePage: NextPage
= async ({ params }) => {
- // get username from params
- // if there the username is started with @ then remove the @
- const username = params?.username?.startsWith("%40")
- ? params.username.replace("%40", "").toLowerCase()
- : params.username.toLowerCase();
-
- const {
- data: { data: profile },
- status,
- } = await http.get<{ data: IUser }>(`/api/profile/username/${username}`, {
- validateStatus: () => true,
- });
-
- if (status === 404) {
- throw new Error("āĻŦā§āϝāĻŦāĻšāĻžāϰāĻāĻžāϰ⧠āĻā§āĻāĻā§ āĻĒāĻžāĻā§āĻž āϝāĻžāĻā§āĻā§ āύāĻž đĨš");
- } else if (status != 200) {
- throw new Error("āĻā§āύ āĻāĻāĻāĻž āϏāĻŽāϏā§āϝāĻž āĻšā§ā§āĻā§ đ§");
- }
-
- return (
-
-
-
-
-
-
-
-
-
-
-
- );
-};
-
-export default UserProfilePage;
diff --git a/src/app/[username]/(profile-page)/_components/ProfilePageAside.tsx b/src/app/[username]/(profile-page)/_components/ProfilePageAside.tsx
new file mode 100644
index 0000000..749b9eb
--- /dev/null
+++ b/src/app/[username]/(profile-page)/_components/ProfilePageAside.tsx
@@ -0,0 +1,229 @@
+import { User } from "@/backend/models/domain-models";
+import Behance from "@/components/icons/behance";
+import Dribbble from "@/components/icons/Dribbble";
+import Facebook from "@/components/icons/facebook";
+import Github from "@/components/icons/github";
+import Instagram from "@/components/icons/instagram";
+import Linkinedin from "@/components/icons/linkinedin";
+import Medium from "@/components/icons/medium";
+import Stackoverflow from "@/components/icons/stackoverflow";
+import Twitch from "@/components/icons/twitch";
+import X from "@/components/icons/x";
+import Youtube from "@/components/icons/youtube";
+import { Link2Icon } from "lucide-react";
+import Link from "next/link";
+import React from "react";
+
+interface ProfilePageAsideProps {
+ profile: User | null;
+}
+
+const ProfilePageAside: React.FC = ({ profile }) => {
+ return (
+
+

+
+
{profile?.name}
+
@{profile?.username}
+
+
+ {profile?.designation && (
+
{profile?.designation}
+ )}
+
+ {profile?.bio && (
+
{profile?.bio}
+ )}
+
+ {/* User infos start */}
+
+ {profile?.website_url && (
+
+ )}
+
+ {profile?.education && (
+
+
+
{profile?.education}
+
+ )}
+
+ {profile?.email && (
+
+
+
{profile?.email}
+
+ )}
+
+ {profile?.location && (
+
+
+
{profile?.location}
+
+ )}
+
+ {/* User infos end */}
+
+ {/* Uer Socials start */}
+
+ {profile?.social_links?.github && (
+
+
+
+ )}
+
+ {profile?.social_links?.facebook && (
+
+
+
+ )}
+
+ {profile?.social_links?.stackOverflow && (
+
+
+
+ )}
+
+ {profile?.social_links?.medium && (
+
+
+
+ )}
+
+ {profile?.social_links?.linkedin && (
+
+
+
+ )}
+
+ {profile?.social_links?.twitter && (
+
+
+
+ )}
+
+ {profile?.social_links?.instagram && (
+
+
+
+ )}
+
+ {profile?.social_links?.behance && (
+
+
+
+ )}
+
+ {profile?.social_links?.dribbble && (
+
+
+
+ )}
+
+ {profile?.social_links?.twitch && (
+
+
+
+ )}
+
+ {profile?.social_links?.youtube && (
+
+
+
+ )}
+
+ {/* User Socials end */}
+
+ );
+};
+
+export default ProfilePageAside;
diff --git a/src/app/[username]/(profile-page)/articles/UserArticleFeed.tsx b/src/app/[username]/(profile-page)/articles/UserArticleFeed.tsx
new file mode 100644
index 0000000..6e6e42e
--- /dev/null
+++ b/src/app/[username]/(profile-page)/articles/UserArticleFeed.tsx
@@ -0,0 +1,75 @@
+"use client";
+
+import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
+import * as articleActions from "@/backend/services/article.actions";
+import React, { useMemo } from "react";
+import ArticleCard from "@/components/ArticleCard";
+import { readingTime } from "@/lib/utils";
+import VisibilitySensor from "@/components/VisibilitySensor";
+
+interface UserArticleFeedProps {
+ userId: string;
+}
+
+const UserArticleFeed: React.FC = ({ userId }) => {
+ const feedInfiniteQuery = useInfiniteQuery({
+ queryKey: ["user-article-feed", userId],
+ queryFn: ({ pageParam }) =>
+ articleActions.userArticleFeed({
+ user_id: userId,
+ limit: 5,
+ page: pageParam,
+ }),
+ initialPageParam: 1,
+ getNextPageParam: (lastPage) => {
+ const _page = lastPage?.meta?.currentPage ?? 1;
+ const _totalPages = lastPage?.meta?.totalPages ?? 1;
+ return _page + 1 <= _totalPages ? _page + 1 : null;
+ },
+ });
+
+ const feedArticles = useMemo(() => {
+ return feedInfiniteQuery.data?.pages.flatMap((page) => page?.nodes);
+ }, [feedInfiniteQuery.data]);
+
+ return (
+ <>
+ {feedInfiniteQuery.isFetching && (
+
+ )}
+
+ {feedArticles?.map((article) => (
+
+ ))}
+
+ {feedInfiniteQuery.hasNextPage && (
+
+ )}
+ >
+ );
+};
+
+export default UserArticleFeed;
diff --git a/src/app/[username]/(profile-page)/articles/page.tsx b/src/app/[username]/(profile-page)/articles/page.tsx
new file mode 100644
index 0000000..8667f76
--- /dev/null
+++ b/src/app/[username]/(profile-page)/articles/page.tsx
@@ -0,0 +1,21 @@
+import { sanitizedUsername } from "@/lib/utils";
+import React from "react";
+import UserArticleFeed from "./UserArticleFeed";
+import { getUserByUsername } from "@/backend/services/user.action";
+
+interface PageProps {
+ params: Promise<{ username: string }>;
+}
+const Page: React.FC = async ({ params }) => {
+ const _params = await params;
+ const username = sanitizedUsername(_params?.username);
+ const profile = await getUserByUsername(username, ["id", "username"]);
+
+ return (
+
+
+
+ );
+};
+
+export default Page;
diff --git a/src/app/[username]/(profile-page)/layout.tsx b/src/app/[username]/(profile-page)/layout.tsx
new file mode 100644
index 0000000..665c195
--- /dev/null
+++ b/src/app/[username]/(profile-page)/layout.tsx
@@ -0,0 +1,116 @@
+import BaseLayout from "@/components/layout/BaseLayout";
+import React from "react";
+import ProfilePageAside from "./_components/ProfilePageAside";
+import _t from "@/i18n/_t";
+import { getUserByUsername } from "@/backend/services/user.action";
+import Image from "next/image";
+import Link from "next/link";
+import { headers } from "next/headers";
+import clsx from "clsx";
+import { sanitizedUsername } from "@/lib/utils";
+
+interface ProfilePageLayoutProps {
+ children: React.ReactNode;
+ params: Promise<{ username: string }>;
+}
+
+const layout: React.FC = async ({
+ children,
+ params,
+}) => {
+ const _headers = await headers();
+ const currentPath = _headers.get("x-current-path");
+ const _params = await params;
+ const username = sanitizedUsername(_params?.username);
+ const profile = await getUserByUsername(username, [
+ // all fields
+ "id",
+ "name",
+ "username",
+ "email",
+ "profile_photo",
+ "education",
+ "designation",
+ "bio",
+ "website_url",
+ "location",
+ "social_links",
+ // "profile_readme",
+ "skills",
+ "created_at",
+ "updated_at",
+ ]);
+
+ if (!profile) {
+ return (
+
+
+
+
+
+
+
+ @{username}
+ {" "}
+ đ {_t("Profile not found")}
+
+
+ {_t("The user you are looking for does not exist")}
+
+
+
+
+ );
+ }
+
+ return (
+
+ {/*
+ {JSON.stringify(
+ { currentPath: sanitizedUsername(currentPath!), username },
+ null,
+ 2
+ )}
+ */}
+
+
+
+
+
+
+ {children}
+
+
+
+
+ );
+};
+
+export default layout;
diff --git a/src/app/[username]/(profile-page)/page.tsx b/src/app/[username]/(profile-page)/page.tsx
new file mode 100644
index 0000000..09039d2
--- /dev/null
+++ b/src/app/[username]/(profile-page)/page.tsx
@@ -0,0 +1,46 @@
+import { getUserByUsername } from "@/backend/services/user.action";
+import _t from "@/i18n/_t";
+import { markdownToHtml } from "@/utils/markdoc-parser";
+import Image from "next/image";
+import React from "react";
+
+interface UserProfilePageProps {
+ params: Promise<{ username: string }>;
+}
+const UserProfilePage: React.FC = async ({ params }) => {
+ const _params = await params;
+ const username = _params?.username?.startsWith("%40")
+ ? _params.username.replaceAll("%40", "").toLowerCase()
+ : _params.username.toLowerCase();
+
+ const profile = await getUserByUsername(username, ["profile_readme"]);
+ // return {JSON.stringify(profile, null, 2)};
+
+ return (
+
+ {profile?.profile_readme ? (
+
+ ) : (
+
+
+
+ {_t("No profile readme found")}
+
+
+ )}
+
+ );
+};
+
+export default UserProfilePage;
diff --git a/src/app/[username]/[articleHandle]/_components/ArticleSidebar.tsx b/src/app/[username]/[articleHandle]/_components/ArticleSidebar.tsx
new file mode 100644
index 0000000..961a8af
--- /dev/null
+++ b/src/app/[username]/[articleHandle]/_components/ArticleSidebar.tsx
@@ -0,0 +1,16 @@
+import { Article } from "@/backend/models/domain-models";
+import UserInformationCard from "@/components/UserInformationCard";
+import React from "react";
+
+interface Props {
+ article: Article;
+}
+const ArticleSidebar: React.FC = ({ article }) => {
+ return (
+
+
+
+ );
+};
+
+export default ArticleSidebar;
diff --git a/src/app/[username]/[articleHandle]/page.tsx b/src/app/[username]/[articleHandle]/page.tsx
new file mode 100644
index 0000000..1b06957
--- /dev/null
+++ b/src/app/[username]/[articleHandle]/page.tsx
@@ -0,0 +1,102 @@
+import BaseLayout from "@/components/layout/BaseLayout";
+import * as articleActions from "@/backend/services/article.actions";
+import React from "react";
+import { Metadata, NextPage } from "next";
+import HomepageLayout from "@/components/layout/HomepageLayout";
+import HomeLeftSidebar from "@/app/(home)/_components/HomeLeftSidebar";
+import { markdownToHtml } from "@/utils/markdoc-parser";
+import AppImage from "@/components/AppImage";
+import Link from "next/link";
+import { readingTime, removeMarkdownSyntax } from "@/lib/utils";
+import { notFound } from "next/navigation";
+import ArticleSidebar from "./_components/ArticleSidebar";
+
+export const metadata: Metadata = {
+ title: "Article detail",
+};
+
+interface ArticlePageProps {
+ params: Promise<{
+ username: string;
+ articleHandle: string;
+ }>;
+}
+
+const Page: NextPage = async ({ params }) => {
+ const _params = await params;
+ const article = await articleActions.articleDetailByHandle(
+ _params.articleHandle
+ );
+
+ if (!article) {
+ throw notFound();
+ }
+
+ const parsedHTML = markdownToHtml(article?.body ?? "");
+
+ return (
+ }
+ RightSidebar={}
+ >
+ {/* {!article && Article not found
} */}
+
+ {article?.cover_image && (
+
+ )}
+
+ {/* User information */}
+
+
+

+
+
+
+
+ {article?.user?.name}
+
+
+
+ ¡
+
+ {readingTime(removeMarkdownSyntax(article?.body ?? "")!)} min
+ read
+
+
+
+
+
+
+
{article?.title ?? ""}
+
+
+
+
+
+ );
+};
+
+export default Page;
diff --git a/src/app/[username]/[articleslug]/LatestArticles.tsx b/src/app/[username]/[articleslug]/LatestArticles.tsx
deleted file mode 100644
index 95a5a02..0000000
--- a/src/app/[username]/[articleslug]/LatestArticles.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import { http } from "@/http/http.client";
-import { IArticleFeedItem } from "@/http/models/Article.model";
-import { PaginatedResponse } from "@/http/models/PaginatedResponse.model";
-import { ArticleApiRepository } from "@/http/repositories/article.repository";
-import Link from "next/link";
-import React from "react";
-
-interface LatestArticlesProps {
- tag?: string;
- excludeIds: string[];
-}
-const LatestArticles: React.FC = async ({
- excludeIds,
-}) => {
- const articleRepository = new ArticleApiRepository();
-
- const { data: articles } = await articleRepository.getArticles({
- limit: 10,
- excludeIds,
- });
-
- return (
-
-
- āϏāĻžāĻŽā§āĻĒā§āϰāϤāĻŋāĻ āϞā§āĻāĻž
-
-
- {articles.map((article) => (
-
- {article.title}
-
- ))}
-
-
- );
-};
-
-export default LatestArticles;
diff --git a/src/app/[username]/[articleslug]/page.tsx b/src/app/[username]/[articleslug]/page.tsx
deleted file mode 100644
index 9c899ad..0000000
--- a/src/app/[username]/[articleslug]/page.tsx
+++ /dev/null
@@ -1,74 +0,0 @@
-import { http } from "@/http/http.client";
-import HomeLeftSidebar from "@/components/asides/HomeLeftSidebar";
-import SocialLogin from "@/components/asides/widgets/SocialLogin";
-import ThreeColumnLayout from "@/components/layout/ThreeColumnLayout";
-import { IArticleDetail } from "@/http/models/Article.model";
-import { markdownToHtml } from "@/utils/markdoc-parser";
-import { Metadata, NextPage } from "next";
-import LatestArticles from "./LatestArticles";
-import AppImage from "@/components/AppImage";
-
-interface ArticleDetailsPageProps {
- params: {
- username: string;
- articleslug: string;
- };
-}
-
-export const metadata: Metadata = {
- title: "Techdiary - A blogging platform for developers",
-};
-
-const ArticleDetailsPage: NextPage = async ({
- params,
-}) => {
- const {
- data: { data: article },
- } = await http.get<{ data: IArticleDetail }>(
- `/api/articles/slug/${params.articleslug}`
- );
-
- const html = markdownToHtml(article?.body?.markdown || "");
-
- return (
- }
- RightSidebar={
-
-
-
-
- }
- >
-
-
- {article?.thumbnail && (
-
- )}
-
-
-
{article.title}
-
-
-
-
-
- {/* */}
-
-
-
-
- );
-};
-
-export default ArticleDetailsPage;
diff --git a/src/app/api/auth/github/callback/route.ts b/src/app/api/auth/github/callback/route.ts
new file mode 100644
index 0000000..8f2dbda
--- /dev/null
+++ b/src/app/api/auth/github/callback/route.ts
@@ -0,0 +1,63 @@
+import { GithubOAuthService } from "@/backend/services/oauth/GithubOAuthService";
+import { RepositoryException } from "@/backend/services/RepositoryException";
+import * as sessionActions from "@/backend/services/session.actions";
+import * as userActions from "@/backend/services/user.repository";
+import { NextResponse } from "next/server";
+
+const githubOAuthService = new GithubOAuthService();
+
+export async function GET(request: Request) {
+ try {
+ const url = new URL(request.url);
+ const code = url.searchParams.get("code");
+ const state = url.searchParams.get("state");
+ const afterAuthRedirect = await sessionActions.getAfterAuthRedirect();
+
+ if (code === null || state === null) {
+ return NextResponse.json({ error: "Please restart the process." });
+ }
+
+ const githubUser = await githubOAuthService.getUserInfo(code!, state!);
+
+ const bootedSocialUser = await userActions.bootSocialUser({
+ service: "github",
+ service_uid: githubUser.id.toString(),
+ name: githubUser.name,
+ username: githubUser.login,
+ email: githubUser.email,
+ profile_photo: githubUser.avatar_url,
+ bio: githubUser.bio,
+ });
+
+ await sessionActions.createLoginSession({
+ user_id: bootedSocialUser?.user.id!,
+ request,
+ });
+
+ if (afterAuthRedirect) {
+ return new Response(null, {
+ status: 302,
+ headers: {
+ Location: afterAuthRedirect ?? "/",
+ },
+ });
+ }
+
+ return new Response(null, {
+ status: 302,
+ headers: {
+ Location: "/",
+ },
+ });
+ } catch (error) {
+ if (error instanceof RepositoryException) {
+ return NextResponse.json({ error: error.toString() }, { status: 400 });
+ }
+ if (error instanceof Error) {
+ return NextResponse.json(
+ { error: "Something went wrong" },
+ { status: 500 }
+ );
+ }
+ }
+}
diff --git a/src/app/api/auth/github/route.ts b/src/app/api/auth/github/route.ts
new file mode 100644
index 0000000..55faf56
--- /dev/null
+++ b/src/app/api/auth/github/route.ts
@@ -0,0 +1,19 @@
+import { GithubOAuthService } from "@/backend/services/oauth/GithubOAuthService";
+import * as sessionActions from "@/backend/services/session.actions";
+const githubOAuthService = new GithubOAuthService();
+
+export async function GET(request: Request): Promise {
+ const url = new URL(request.url);
+ const authorizationUrl = await githubOAuthService.getAuthorizationUrl();
+ const next = url.searchParams.get("next");
+ if (next) {
+ await sessionActions.setAfterAuthRedirect(next);
+ }
+
+ return new Response(null, {
+ status: 302,
+ headers: {
+ Location: authorizationUrl,
+ },
+ });
+}
diff --git a/src/app/api/cache-tags-revalidate/route.ts b/src/app/api/cache-tags-revalidate/route.ts
deleted file mode 100644
index 3a0ca34..0000000
--- a/src/app/api/cache-tags-revalidate/route.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { revalidateTag } from "next/cache";
-
-export const GET = async (request: Request) => {
- const { searchParams } = new URL(request.url);
- const tagName = searchParams.get("tagName");
-
- if (!tagName) {
- return new Response("Missing tagName");
- }
-
- revalidateTag(tagName!);
- return new Response("Cache cleared");
-};
diff --git a/src/app/api/clear-cookies/route.ts b/src/app/api/clear-cookies/route.ts
deleted file mode 100644
index b805d22..0000000
--- a/src/app/api/clear-cookies/route.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { cookies } from "next/headers";
-import { NextRequest } from "next/server";
-
-export const POST = async (request: NextRequest) => {
- try {
- // clear cookies
- cookies().delete({
- name: "techdiaryapi_session",
- path: "/",
- domain: ".techdiary.test",
- httpOnly: true,
- });
- cookies().delete({
- name: "techdiaryapi_session",
- path: "/",
- domain: ".techdiary.test",
- httpOnly: true,
- });
- return new Response(null, { status: 200 });
- } catch (error) {
- return new Response(JSON.stringify(error), { status: 500 });
- }
-};
diff --git a/src/app/api/cloudinary/route.ts b/src/app/api/cloudinary/route.ts
deleted file mode 100644
index b40b529..0000000
--- a/src/app/api/cloudinary/route.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import { NextRequest } from "next/server";
-import { v2 as cloudinary } from "cloudinary";
-// const cloudinary = require("cloudinary").v2;
-
-cloudinary.config({ secure: true });
-
-export const POST = async (request: NextRequest) => {
- try {
- const fd = await request.formData();
- console.log(fd.get("file"));
- const _file = fd.get("file");
- // const result = await cloudinary.uploader.upload(_file, {
- // public_id: fd.get("public_id"),
- // folder: fd.get("folder"),
- // });
- // upload to cloudinary
- // return Response.json(result);
- // return new Response(JSON.stringify(result), { status: 200 });
- } catch (error) {
- return new Response(JSON.stringify(error), { status: 500 });
- }
-};
diff --git a/src/app/api/cloudinary/upload-by-url/route.ts b/src/app/api/cloudinary/upload-by-url/route.ts
deleted file mode 100644
index f8ff5ff..0000000
--- a/src/app/api/cloudinary/upload-by-url/route.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { v2 as cloudinary } from "cloudinary";
-import { NextRequest } from "next/server";
-// const cloudinary = require("cloudinary").v2;
-
-cloudinary.config({ secure: true });
-
-export const POST = async (request: NextRequest) => {
- try {
- const body = await request.json();
- const result = await cloudinary.uploader.upload(body.url, {
- public_id: body?.public_id,
- preset: "techdiary-article-covers",
- });
-
- return Response.json(result);
- } catch (error) {
- return new Response(JSON.stringify(error), { status: 500 });
- }
-};
diff --git a/src/app/api/play/route.ts b/src/app/api/play/route.ts
new file mode 100644
index 0000000..dc82ddf
--- /dev/null
+++ b/src/app/api/play/route.ts
@@ -0,0 +1,9 @@
+import { slugify } from "@/lib/slug-helper.util";
+import { NextResponse } from "next/server";
+
+export async function GET(request: Request) {
+ // const _headers = await headers();
+ return NextResponse.json({
+ slug: slugify("āĻā§āĻŽāύ āĻāĻā§āύ āĻāĻĒāύāĻžāϰāĻž?"),
+ });
+}
diff --git a/src/app/api/unsplash/route.ts b/src/app/api/unsplash/route.ts
deleted file mode 100644
index bf44812..0000000
--- a/src/app/api/unsplash/route.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { NextRequest } from "next/server";
-
-export const GET = async (request: NextRequest) => {
- try {
- const searchParams = request.nextUrl.searchParams;
-
- const params = new URLSearchParams({
- client_id: process.env.UNSPLASH_CLIENT_ID!,
- query: searchParams.get("query") || "",
- per_page: searchParams.get("per_page") || "10",
- page: searchParams.get("page") || "1",
- });
-
- const api = await fetch(
- "https://api.unsplash.com/search/photos?" + params.toString()
- );
- const data = await api.json();
-
- return Response.json(data);
- } catch (error) {
- return new Response(JSON.stringify(error), { status: 500 });
- }
-};
diff --git a/src/app/auth/forgot-password/page.tsx b/src/app/auth/forgot-password/page.tsx
deleted file mode 100644
index 0d9c4a0..0000000
--- a/src/app/auth/forgot-password/page.tsx
+++ /dev/null
@@ -1,90 +0,0 @@
-"use client";
-import { useForm } from "react-hook-form";
-import * as yup from "yup";
-import { yupResolver } from "@hookform/resolvers/yup";
-import { ErrorMessage } from "@hookform/error-message";
-import BaseLayout from "@/components/layout/BaseLayout";
-import { Alert, Button, Input, Text } from "@mantine/core";
-import Link from "next/link";
-import React from "react";
-import { AuthRepository } from "@/http/repositories/auth.repository";
-import { useMutation } from "@tanstack/react-query";
-import { ensureCSRF } from "@/utils/ensureCSRF";
-import { showNotification } from "@mantine/notifications";
-import { AxiosError } from "axios";
-import {
- getHookFormErrorMessage,
- getServerErrorMessage,
-} from "@/utils/form-error";
-
-const ForgotPasswordPage = () => {
- const api = new AuthRepository();
- const mutation = useMutation({
- mutationFn: (payload: ILoginFormPayload) => api.forgotPassword(payload),
- });
-
- const form = useForm({
- resolver: yupResolver(formValidationSchema),
- });
-
- const onSubmit = async (data: ILoginFormPayload) => {
- await ensureCSRF(() => {
- mutation.mutate(data, {
- onSuccess: () => {
- window.location.href = "/";
- },
- });
- });
- };
-
- return (
-
-
-
-
-
-
- āĻĒāĻžāϏāϏā§ā§āĻžāϰā§āĻĄ āĻā§āϞ⧠āĻā§āĻā§āύ?
-
-
-
- āĻāĻāĻžāĻāύā§āĻ āύāĻŋāĻŦāύā§āϧāύ āĻāϰā§āύ
-
-
-
-
- );
-};
-
-const formValidationSchema = yup.object({
- email: yup.string().email().required().label("Email"),
-});
-
-type ILoginFormPayload = yup.InferType;
-
-export default ForgotPasswordPage;
diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx
deleted file mode 100644
index 17c336f..0000000
--- a/src/app/auth/login/page.tsx
+++ /dev/null
@@ -1,107 +0,0 @@
-"use client";
-import { useForm } from "react-hook-form";
-import * as yup from "yup";
-import { yupResolver } from "@hookform/resolvers/yup";
-import { ErrorMessage } from "@hookform/error-message";
-import BaseLayout from "@/components/layout/BaseLayout";
-import { Alert, Button, Input, Text } from "@mantine/core";
-import Link from "next/link";
-import React from "react";
-import { AuthRepository } from "@/http/repositories/auth.repository";
-import { useMutation } from "@tanstack/react-query";
-import { ensureCSRF } from "@/utils/ensureCSRF";
-import { showNotification } from "@mantine/notifications";
-import { AxiosError } from "axios";
-
-import {
- getHookFormErrorMessage,
- getServerErrorMessage,
-} from "@/utils/form-error";
-import { useTranslation } from "@/i18n/use-translation";
-
-const LoginPage = () => {
- const { _t } = useTranslation();
- const api = new AuthRepository();
- const loginMutation = useMutation({
- mutationFn: (payload: ILoginFormPayload) => api.login(payload),
- });
-
- const form = useForm({
- resolver: yupResolver(formValidationSchema),
- });
-
- const onSubmit = async (data: ILoginFormPayload) => {
- await ensureCSRF(() => {
- loginMutation.mutate(data, {
- onSuccess: () => {
- window.location.href = "/";
- },
- });
- });
- };
-
- return (
-
-
-
-
-
-
- {_t("Forgot password?")}
-
-
-
- {_t("Create an account")}
-
-
-
-
- );
-};
-
-const formValidationSchema = yup.object({
- email: yup.string().email().required().label("Email"),
- password: yup.string().min(6).max(32).required().label("Password"),
-});
-
-type ILoginFormPayload = yup.InferType;
-
-export default LoginPage;
diff --git a/src/app/auth/oauth-callback/page.tsx b/src/app/auth/oauth-callback/page.tsx
deleted file mode 100644
index 0137f59..0000000
--- a/src/app/auth/oauth-callback/page.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-"use client";
-
-import { http } from "@/http/http.client";
-import BaseLayout from "@/components/layout/BaseLayout";
-import { Loader, Space, Text } from "@mantine/core";
-import { useSearchParams } from "next/navigation";
-import { Suspense, useEffect, useState } from "react";
-import { useTranslation } from "@/i18n/use-translation";
-
-const OAuthCallbackClient = () => {
- const searchParams = useSearchParams();
- const { _t } = useTranslation();
-
- useEffect(() => {
- http.get("sanctum/csrf-cookie").then((res) => {
- http
- .post(`/api/auth/signed-login?${searchParams.toString()}`)
- .then((_res) => {
- http.post("api/auth/login-spark").then((res) => {
- console.log({
- res,
- _res,
- });
- window.location.href = localStorage.getItem("redirect_uri") || "/";
- });
- });
- });
- }, [searchParams]);
-
- return (
-
-
-
-
-
-
- {_t("Please wail, you are being redirected to the home page")}
-
-
-
-
- );
-};
-
-const OAuthCallback = () => {
- return (
- Please wait...}>
-
-
- );
-};
-
-export default OAuthCallback;
diff --git a/src/app/auth/register/page.tsx b/src/app/auth/register/page.tsx
deleted file mode 100644
index 8f559a8..0000000
--- a/src/app/auth/register/page.tsx
+++ /dev/null
@@ -1,176 +0,0 @@
-"use client";
-import { useForm } from "react-hook-form";
-import * as yup from "yup";
-import { yupResolver } from "@hookform/resolvers/yup";
-import { ErrorMessage } from "@hookform/error-message";
-import BaseLayout from "@/components/layout/BaseLayout";
-import { Alert, Button, Input, Text } from "@mantine/core";
-import Link from "next/link";
-import React from "react";
-import { AuthRepository } from "@/http/repositories/auth.repository";
-import { useMutation } from "@tanstack/react-query";
-import { ensureCSRF } from "@/utils/ensureCSRF";
-import { showNotification } from "@mantine/notifications";
-import { AxiosError } from "axios";
-
-import {
- getHookFormErrorMessage,
- getServerErrorMessage,
-} from "@/utils/form-error";
-import { useTranslation } from "@/i18n/use-translation";
-import { useDebouncedCallback } from "@mantine/hooks";
-import { ProfileApiRepository } from "@/http/repositories/profile.repository";
-
-const RegisterPage = () => {
- const { _t } = useTranslation();
- const api = new AuthRepository();
- const api__profile = new ProfileApiRepository();
- const registrationMutation = useMutation({
- mutationFn: (payload: IRegistrationFormPayload) => api.register(payload),
- });
-
- const form = useForm({
- resolver: yupResolver(formValidationSchema),
- });
-
- const handleOnChangeUsernameDebounce = useDebouncedCallback(
- (username: string) => {
- ensureCSRF(async () => {
- await api__profile.getPublicUniqueUsername(username).then((res) => {
- if (res?.data?.username) {
- form.setValue("username", res?.data?.username);
- }
- });
- });
- },
- 500
- );
-
- const onSubmit = async (data: IRegistrationFormPayload) => {
- await ensureCSRF(() => {
- registrationMutation.mutate(data, {
- onSuccess: () => {
- window.location.href = "/";
- },
- });
- });
- };
-
- return (
-
-
-
-
-
- {_t("Already have an account?")}{" "}
-
- {_t("Login")}
-
-
-
-
- );
-};
-
-const formValidationSchema = yup.object({
- name: yup.string().required().label("Name"),
- username: yup.string().required().label("Username"),
- email: yup.string().email().required().label("Email"),
- password: yup.string().min(6).max(32).required().label("Password"),
- password_confirmation: yup
- .string()
- .min(6)
- .max(32)
- .required()
- .label("Password"),
-});
-
-type IRegistrationFormPayload = yup.InferType;
-
-export default RegisterPage;
diff --git a/src/app/auth/reset-password/page.tsx b/src/app/auth/reset-password/page.tsx
deleted file mode 100644
index d18a1d8..0000000
--- a/src/app/auth/reset-password/page.tsx
+++ /dev/null
@@ -1,138 +0,0 @@
-"use client";
-import { useForm } from "react-hook-form";
-import * as yup from "yup";
-import { yupResolver } from "@hookform/resolvers/yup";
-import { ErrorMessage } from "@hookform/error-message";
-import BaseLayout from "@/components/layout/BaseLayout";
-import { Alert, Button, Input, Text } from "@mantine/core";
-import Link from "next/link";
-import React from "react";
-import { AuthRepository } from "@/http/repositories/auth.repository";
-import { useMutation } from "@tanstack/react-query";
-import { ensureCSRF } from "@/utils/ensureCSRF";
-import { showNotification } from "@mantine/notifications";
-import { AxiosError } from "axios";
-import { useRouter, useSearchParams } from "next/navigation";
-import {
- getHookFormErrorMessage,
- getServerErrorMessage,
-} from "@/utils/form-error";
-
-const ResetPasswordPage = () => {
- const api = new AuthRepository();
- const searchParams = useSearchParams();
- const router = useRouter();
-
- const mutation = useMutation({
- mutationFn: (payload: {
- token: string;
- email: string;
- password: string;
- password_confirmation: string;
- }) => api.resetPassword(payload),
- });
-
- const form = useForm({
- resolver: yupResolver(formValidationSchema),
- });
-
- const onSubmit = async (data: ILoginFormPayload) => {
- await ensureCSRF(() => {
- mutation.mutate(
- {
- email: searchParams.get("email") || "",
- token: searchParams.get("token") || "",
- ...data,
- },
- {
- onSuccess: () => {
- showNotification({
- title: "Password has been reset successfully",
- message: "",
- });
- router.push("/auth/login");
- },
- }
- );
- });
- };
-
- return (
-
-
-
-
-
-
- āĻĒāĻžāϏāϏā§ā§āĻžāϰā§āĻĄ āĻā§āϞ⧠āĻā§āĻā§āύ?
-
-
-
- āĻāĻāĻžāĻāύā§āĻ āύāĻŋāĻŦāύā§āϧāύ āĻāϰā§āύ
-
-
-
-
- );
-};
-
-const formValidationSchema = yup.object({
- password: yup.string().required().label("Password"),
- password_confirmation: yup.string().required().label("Password Confirmation"),
-});
-
-type ILoginFormPayload = yup.InferType;
-
-export default ResetPasswordPage;
diff --git a/src/app/dashboard/_components/ArticleList.tsx b/src/app/dashboard/_components/ArticleList.tsx
new file mode 100644
index 0000000..245ec1e
--- /dev/null
+++ b/src/app/dashboard/_components/ArticleList.tsx
@@ -0,0 +1,181 @@
+"use client";
+
+import * as articleActions from "@/backend/services/article.actions";
+import { useAppConfirm } from "@/components/app-confirm";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import VisibilitySensor from "@/components/VisibilitySensor";
+import { useTranslation } from "@/i18n/use-translation";
+import { formattedTime } from "@/lib/utils";
+import {
+ CardStackIcon,
+ DotsHorizontalIcon,
+ Pencil1Icon,
+ PlusIcon,
+} from "@radix-ui/react-icons";
+
+import { useInfiniteQuery } from "@tanstack/react-query";
+import Link from "next/link";
+
+const ArticleList = () => {
+ const { _t } = useTranslation();
+ const feedInfiniteQuery = useInfiniteQuery({
+ queryKey: ["dashboard-articles"],
+ queryFn: ({ pageParam }) =>
+ articleActions.myArticles({ limit: 10, page: pageParam }),
+ initialPageParam: 1,
+ getNextPageParam: (lastPage) => {
+ const _page = lastPage?.meta?.currentPage ?? 1;
+ const _totalPages = lastPage?.meta?.totalPages ?? 1;
+ return _page + 1 <= _totalPages ? _page + 1 : null;
+ },
+ });
+
+ const appConfirm = useAppConfirm();
+
+ return (
+
+
+
{_t("Articles")}
+
+
+
+
+
+ {feedInfiniteQuery.isFetching &&
+ Array.from({ length: 10 }).map((_, i) => (
+
+ ))}
+
+ {feedInfiniteQuery.data?.pages.map((page) => {
+ return page?.nodes.map((article) => (
+
+
+
{article.handle}
+
+ {article.title}
+
+ {article.is_published && (
+
+ {_t("Published on")} {formattedTime(article.published_at!)}
+
+ )}
+
+
+
+
+ {!article.approved_at && (
+
+ đ§ {_t("āĻ
āύā§āĻŽā§āĻĻāύāĻžāϧā§āύ")}
+
+ )}
+
+ {article.approved_at && (
+
+ â
{_t("āĻ
āύā§āĻŽā§āĻĻāĻŋāϤ")}
+
+ )}
+
+ {!article.is_published && (
+
+ đ§ {_t("Draft")}
+
+ )}
+
+ {article.is_published && (
+
+ â
{_t("Published")}
+
+ )}
+
+ {/*
+
+
{article?.comments_count || 0}
+
*/}
+
+ {/*
+
+
{article?.votes?.score || 0}
+
*/}
+
+
+
+
+
+
+
+
+
+ {_t("Edit")}
+
+
+
+
+
+
+
+
+
+
+ ));
+ })}
+
+
+ {feedInfiniteQuery.hasNextPage && (
+
+ )}
+
+ );
+};
+
+export default ArticleList;
diff --git a/src/app/dashboard/_components/DashboardLayout.tsx b/src/app/dashboard/_components/DashboardLayout.tsx
deleted file mode 100644
index 2f687b8..0000000
--- a/src/app/dashboard/_components/DashboardLayout.tsx
+++ /dev/null
@@ -1,50 +0,0 @@
-"use client";
-
-import Navbar from "@/components/navbar/Navbar";
-import { AppShell, Burger, Drawer } from "@mantine/core";
-import { useDisclosure } from "@mantine/hooks";
-import React, { PropsWithChildren } from "react";
-import DashboardNavbar from "./DashboardNavbar";
-
-const DashboardLayout: React.FC = ({ children }) => {
- const [opened, { toggle, close }] = useDisclosure(false);
-
- return (
- <>
-
-
-
-
-
-
-
- }
- />
-
-
-
-
- {children}
-
- >
- );
-};
-
-export default DashboardLayout;
diff --git a/src/app/dashboard/_components/DashboardNavbar.tsx b/src/app/dashboard/_components/DashboardNavbar.tsx
deleted file mode 100644
index 4d267fa..0000000
--- a/src/app/dashboard/_components/DashboardNavbar.tsx
+++ /dev/null
@@ -1,70 +0,0 @@
-import { useTranslation } from "@/i18n/use-translation";
-import { NavLink, Skeleton } from "@mantine/core";
-import {
- BellIcon,
- BookmarkIcon,
- DashboardIcon,
- GearIcon,
- LockOpen1Icon,
-} from "@radix-ui/react-icons";
-import Link from "next/link";
-import { usePathname } from "next/navigation";
-import React from "react";
-
-interface INavItem {
- name: string;
- path: string;
- Icon?: React.ForwardRefExoticComponent<
- any & React.RefAttributes
- >;
-}
-
-const DashboardNavbar = () => {
- const { _t } = useTranslation();
- const routerPath = usePathname();
- const items: INavItem[] = [
- { name: _t("Dashboard"), path: "", Icon: DashboardIcon },
- {
- name: _t("Notifications"),
- path: "notifications",
- Icon: BellIcon,
- },
- { name: _t("Bookmarks"), path: "bookmarks", Icon: BookmarkIcon },
- {
- name: _t("Settings"),
- path: "settings",
- Icon: GearIcon,
- },
- {
- name: _t("Personal access token"),
- path: "personal-access-token",
- Icon: LockOpen1Icon,
- },
- ];
-
- const getIsActive = (path: string) => {
- const _routerPath = routerPath.replace("/dashboard", "").replace("/", "");
- return _routerPath === path;
- };
-
- return (
-
- {items.map((item) => (
- :
- }
- />
- ))}
-
- );
-};
-
-export default DashboardNavbar;
diff --git a/src/app/dashboard/_components/DashboardScaffold.tsx b/src/app/dashboard/_components/DashboardScaffold.tsx
new file mode 100644
index 0000000..1c7a87d
--- /dev/null
+++ b/src/app/dashboard/_components/DashboardScaffold.tsx
@@ -0,0 +1,97 @@
+import {
+ Sidebar,
+ SidebarContent,
+ SidebarFooter,
+ SidebarGroupContent,
+ SidebarHeader,
+ SidebarMenu,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ SidebarProvider,
+ SidebarTrigger,
+} from "@/components/ui/sidebar";
+import {
+ Calendar,
+ CommandIcon,
+ Home,
+ Inbox,
+ Search,
+ Settings,
+} from "lucide-react";
+import React, { PropsWithChildren } from "react";
+import DashboardSidebar from "./DashboardSidebar";
+import AuthenticatedUserMenu from "@/components/Navbar/AuthenticatedUserMenu";
+import ThemeSwitcher from "@/components/Navbar/ThemeSwitcher";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import LanguageSwitcher from "@/components/Navbar/LanguageSwitcher";
+
+interface DashboardScaffoldProps {}
+
+const items = [
+ {
+ title: "Home",
+ url: "#",
+ icon: Home,
+ },
+ {
+ title: "Inbox",
+ url: "#",
+ icon: Inbox,
+ },
+ {
+ title: "Calendar",
+ url: "#",
+ icon: Calendar,
+ },
+ {
+ title: "Search",
+ url: "#",
+ icon: Search,
+ },
+ {
+ title: "Settings",
+ url: "#",
+ icon: Settings,
+ },
+];
+
+const DashboardScaffold: React.FC<
+ PropsWithChildren
+> = ({ children }) => {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+ +
+ b
+
+
+
+
+
+
+ {children}
+
+ >
+ );
+};
+
+export default DashboardScaffold;
diff --git a/src/app/dashboard/_components/DashboardSidebar.tsx b/src/app/dashboard/_components/DashboardSidebar.tsx
new file mode 100644
index 0000000..335b271
--- /dev/null
+++ b/src/app/dashboard/_components/DashboardSidebar.tsx
@@ -0,0 +1,86 @@
+"use client";
+
+import {
+ Sidebar,
+ SidebarContent,
+ SidebarGroup,
+ SidebarGroupContent,
+ SidebarGroupLabel,
+ SidebarMenu,
+ SidebarMenuButton,
+ SidebarMenuItem,
+} from "@/components/ui/sidebar";
+import { useTranslation } from "@/i18n/use-translation";
+import {
+ BellIcon,
+ Bookmark,
+ Home,
+ KeySquareIcon,
+ Settings,
+ Settings2,
+} from "lucide-react";
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+
+const DashboardSidebar = () => {
+ const { _t } = useTranslation();
+ const pathName = usePathname();
+ const items = [
+ {
+ title: _t("Dashboard"),
+ url: "",
+ icon: Home,
+ },
+ {
+ title: _t("Notifications"),
+ url: "/notifications",
+ icon: BellIcon,
+ },
+ {
+ title: _t("Bookmarks"),
+ url: "/bookmarks",
+ icon: Bookmark,
+ },
+ {
+ title: _t("Settings"),
+ url: "/settings",
+ icon: Settings2,
+ },
+ {
+ title: _t("Login Sessions"),
+ url: "/sessions",
+ icon: KeySquareIcon,
+ },
+ ];
+ return (
+
+
+
+ {_t("Dashboard")}
+
+
+ {items.map((item, key) => (
+
+
+
+
+ {item.title}
+
+
+
+ ))}
+
+
+
+
+
+ );
+};
+
+export default DashboardSidebar;
diff --git a/src/app/dashboard/_components/MatrixReport.tsx b/src/app/dashboard/_components/MatrixReport.tsx
new file mode 100644
index 0000000..380be0f
--- /dev/null
+++ b/src/app/dashboard/_components/MatrixReport.tsx
@@ -0,0 +1,64 @@
+"use client";
+
+import { myArticleMatrix } from "@/backend/services/dashboard.action";
+import { Card, CardContent } from "@/components/ui/card";
+import { useTranslation } from "@/i18n/use-translation";
+import { useQuery } from "@tanstack/react-query";
+import { LoaderIcon } from "lucide-react";
+import React from "react";
+
+const MatrixReport = () => {
+ const { _t } = useTranslation();
+
+ const query = useQuery({
+ queryKey: ["dashboard-matrix-report"],
+ queryFn: () => myArticleMatrix(),
+ });
+
+ return (
+
+
{_t("Stats")}
+
+
+
+
+
+ {query.isFetching && (
+
+ )}
+ {query.data?.total_articles}
+
+
+ {_t("Total articles")}
+
+
+
+
+
+
+
+ {query.isFetching && (
+
+ )}
+ {query.data?.total_comments}
+
+
+ {_t("Total post comments")}
+
+
+
+
+ {/*
+
+ 1334
+
+ {_t("Total post reactions")}
+
+
+ */}
+
+
+ );
+};
+
+export default MatrixReport;
diff --git a/src/app/dashboard/_components/ViewsChart.tsx b/src/app/dashboard/_components/ViewsChart.tsx
new file mode 100644
index 0000000..5017038
--- /dev/null
+++ b/src/app/dashboard/_components/ViewsChart.tsx
@@ -0,0 +1,90 @@
+"use client";
+
+import {
+ ChartContainer,
+ ChartTooltip,
+ ChartTooltipContent,
+} from "@/components/ui/chart";
+import React from "react";
+import { Bar, BarChart, CartesianGrid, Line, LineChart, XAxis } from "recharts";
+
+import { type ChartConfig } from "@/components/ui/chart";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { TrendingUp } from "lucide-react";
+import { useTranslation } from "@/i18n/use-translation";
+
+const chartConfig = {
+ views: {
+ label: "Article Views:",
+ color: "var(--primary)",
+ },
+} satisfies ChartConfig;
+
+const ViewsChart = () => {
+ const { _t } = useTranslation();
+ const data = [
+ { month: "January", views: 186 },
+ { month: "February", views: 305 },
+ { month: "March", views: 237 },
+ { month: "April", views: 73 },
+ { month: "May", views: 209 },
+ { month: "June", views: 214 },
+ { month: "July", views: 214 },
+ { month: "August", views: 214 },
+ { month: "September", views: 214 },
+ { month: "October", views: 214 },
+ { month: "November", views: 214 },
+ { month: "December", views: 214 },
+ ];
+
+ return (
+
+
+ {_t("Article views report")}
+ {/* January - June 2024 */}
+
+
+
+
+
+ value.slice(0, 3)}
+ />
+ }
+ />
+
+
+
+
+
+ );
+};
+
+export default ViewsChart;
diff --git a/src/app/dashboard/articles/archived/page.tsx b/src/app/dashboard/articles/archived/page.tsx
deleted file mode 100644
index 8b1fe96..0000000
--- a/src/app/dashboard/articles/archived/page.tsx
+++ /dev/null
@@ -1,151 +0,0 @@
-"use client";
-
-import { IArticleFeedItem } from "@/http/models/Article.model";
-import { PaginatedResponse } from "@/http/models/PaginatedResponse.model";
-import { ArticleApiRepository } from "@/http/repositories/article.repository";
-import { useTranslation } from "@/i18n/use-translation";
-import { relativeTime } from "@/utils/relativeTime";
-import { Loader, Menu } from "@mantine/core";
-import { openConfirmModal } from "@mantine/modals";
-import {
- CardStackIcon,
- ChatBubbleIcon,
- DotsHorizontalIcon,
- Pencil1Icon,
- ThickArrowUpIcon,
-} from "@radix-ui/react-icons";
-import { useInfiniteQuery } from "@tanstack/react-query";
-import Link from "next/link";
-import { useRouter } from "next/navigation";
-import React from "react";
-import { VisibilityObserver } from "reactjs-visibility";
-
-const ArchievedArticlesPage = () => {
- const api = new ArticleApiRepository();
- const { _t } = useTranslation();
- const apiRepo = new ArticleApiRepository();
- const router = useRouter();
-
- const { data, fetchNextPage, hasNextPage } = useInfiniteQuery<
- PaginatedResponse
- >({
- queryKey: ["dashboard-archived-articles"],
- initialPageParam: 1,
- getNextPageParam: (lastPage, allPages) => {
- return allPages.length < lastPage.meta.last_page
- ? lastPage.meta.current_page + 1
- : null;
- },
- queryFn: async ({ pageParam }) => {
- return apiRepo.getMyArchivedArticles({
- page: pageParam as number,
- limit: 10,
- });
- },
- });
-
- return (
-
- {/*
{JSON.stringify(initialArticles, null, 2)} */}
- {data?.pages.map((page) => {
- return page.data.map((article) => (
-
-
-
- {article.title}
-
- {article.is_published && (
-
- {_t("Published on")}{" "}
- {relativeTime(new Date(article.published_at))}
-
- )}
-
-
-
-
- {!article.is_published && (
-
- đ§ {_t("Draft")}
-
- )}
-
-
-
-
{article?.comments_count || 0}
-
-
-
-
-
{article?.votes?.score || 0}
-
-
-
-
-
- ));
- })}
-
- {hasNextPage && (
-
{
- if (isVisible) {
- fetchNextPage();
- }
- }}
- options={{ rootMargin: "200px" }}
- >
-
-
-
-
- )}
-
- );
-};
-
-export default ArchievedArticlesPage;
diff --git a/src/app/dashboard/bookmarks/_components/ArticleBookmarks.tsx b/src/app/dashboard/bookmarks/_components/ArticleBookmarks.tsx
deleted file mode 100644
index a5664d8..0000000
--- a/src/app/dashboard/bookmarks/_components/ArticleBookmarks.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-import { BookmarkRepository } from "@/http/repositories/bookmark.repository";
-import { useTranslation } from "@/i18n/use-translation";
-import { Menu } from "@mantine/core";
-import {
- CardStackIcon,
- DotsHorizontalIcon,
- Pencil1Icon,
-} from "@radix-ui/react-icons";
-import { useQuery } from "@tanstack/react-query";
-import Link from "next/link";
-import React from "react";
-
-const ArticleBookmarks = () => {
- const { _t } = useTranslation();
- const api = new BookmarkRepository();
- const { data } = useQuery({
- queryKey: ["dashboard:bookmarks"],
- queryFn: async () => {
- const { data } = await api.getBookmarks({
- limit: -1,
- model_name: "ARTICLE",
- });
- return data;
- },
- });
- return (
-
- {data?.data?.map((article) => (
-
-
-
- {article.title}
-
-
-
- ))}
-
- );
-};
-
-export default ArticleBookmarks;
diff --git a/src/app/dashboard/bookmarks/_components/CommentBookmarks.tsx b/src/app/dashboard/bookmarks/_components/CommentBookmarks.tsx
deleted file mode 100644
index ce81b8c..0000000
--- a/src/app/dashboard/bookmarks/_components/CommentBookmarks.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-import React from "react";
-
-const CommentBookmarks = () => {
- return CommentBookmarks
;
-};
-
-export default CommentBookmarks;
diff --git a/src/app/dashboard/bookmarks/page.client.tsx b/src/app/dashboard/bookmarks/page.client.tsx
deleted file mode 100644
index 1bbced5..0000000
--- a/src/app/dashboard/bookmarks/page.client.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-"use client";
-import { BookmarkRepository } from "@/http/repositories/bookmark.repository";
-import { Tabs } from "@mantine/core";
-import { useQuery } from "@tanstack/react-query";
-import React from "react";
-import ArticleBookmarks from "./_components/ArticleBookmarks";
-import CommentBookmarks from "./_components/CommentBookmarks";
-import { useTranslation } from "@/i18n/use-translation";
-
-const BookmarksPage = () => {
- const { _t } = useTranslation();
- return (
-
-
- {_t("Diaries")}
- {_t("Comments")}
-
-
-
-
-
-
-
-
- );
-};
-
-export default BookmarksPage;
diff --git a/src/app/dashboard/bookmarks/page.tsx b/src/app/dashboard/bookmarks/page.tsx
index eaae248..79e8aef 100644
--- a/src/app/dashboard/bookmarks/page.tsx
+++ b/src/app/dashboard/bookmarks/page.tsx
@@ -1,12 +1,13 @@
-import { Metadata } from "next";
-import BookmarksPage from "./page.client";
+import { ExclamationTriangleIcon } from "@radix-ui/react-icons";
+import React from "react";
-export const metadata: Metadata = {
- title: "Bookmarks",
+const BookmarksPage = () => {
+ return (
+
+
+
āĻāĻ āĻĢāĻŋāĻāĻžāϰ āĻāĻāύ⧠āĻļā§āώ āĻšā§āύāĻŋ
+
+ );
};
-const BookmarkPage = () => {
- return ;
-};
-
-export default BookmarkPage;
+export default BookmarksPage;
diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx
index 7bdfb95..5e2dc6c 100644
--- a/src/app/dashboard/layout.tsx
+++ b/src/app/dashboard/layout.tsx
@@ -1,16 +1,35 @@
-import { ssrGetMe } from "@/utils/ssr-user";
+import * as sessionActions from "@/backend/services/session.actions";
+import { SidebarProvider } from "@/components/ui/sidebar";
+import { Metadata } from "next";
+import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import React, { PropsWithChildren } from "react";
-import DashboardLayout from "./_components/DashboardLayout";
-import Image from "next/image";
+import DashboardScaffold from "./_components/DashboardScaffold";
+
+export const metadata: Metadata = {
+ title: {
+ default: "Dashboard",
+ template: "%s | TechDiary",
+ },
+};
const layout: React.FC = async ({ children }) => {
- const { status } = await ssrGetMe();
- if (status !== 200) {
- redirect("/auth/login");
+ const _headers = await headers();
+ const currentPath = _headers.get("x-current-path");
+ const session = await sessionActions.getSession();
+
+ const cookieStore = await cookies();
+ const defaultOpen = cookieStore.get("sidebar_state")?.value === "true";
+
+ if (!session?.user) {
+ redirect(`/login?next=${currentPath}`);
}
- return {children};
+ return (
+
+ {children}
+
+ );
};
export default layout;
diff --git a/src/app/dashboard/loading.tsx b/src/app/dashboard/loading.tsx
deleted file mode 100644
index 245e541..0000000
--- a/src/app/dashboard/loading.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import Image from "next/image";
-import React from "react";
-
-const loading = () => {
- return (
-
-
-
- );
-};
-
-export default loading;
diff --git a/src/app/dashboard/notifications/page.tsx b/src/app/dashboard/notifications/page.tsx
index d74378c..4bd8632 100644
--- a/src/app/dashboard/notifications/page.tsx
+++ b/src/app/dashboard/notifications/page.tsx
@@ -1,7 +1,12 @@
-import React from "react";
+import { ExclamationTriangleIcon } from "@radix-ui/react-icons";
-const NotificationsPage = () => {
- return NotificationsPage
;
+const NotificationPage = () => {
+ return (
+
+
+
āĻāĻ āĻĢāĻŋāĻāĻžāϰ āĻāĻāύ⧠āĻļā§āώ āĻšā§āύāĻŋ
+
+ );
};
-export default NotificationsPage;
+export default NotificationPage;
diff --git a/src/app/dashboard/page.client.tsx b/src/app/dashboard/page.client.tsx
deleted file mode 100644
index f6e6f64..0000000
--- a/src/app/dashboard/page.client.tsx
+++ /dev/null
@@ -1,170 +0,0 @@
-"use client";
-
-import { IArticleFeedItem } from "@/http/models/Article.model";
-import { PaginatedResponse } from "@/http/models/PaginatedResponse.model";
-import { ArticleApiRepository } from "@/http/repositories/article.repository";
-import { useTranslation } from "@/i18n/use-translation";
-import { relativeTime } from "@/utils/relativeTime";
-import { Loader, Menu, Text } from "@mantine/core";
-import { openConfirmModal } from "@mantine/modals";
-import {
- CardStackIcon,
- ChatBubbleIcon,
- DotsHorizontalIcon,
- Pencil1Icon,
- ThickArrowUpIcon,
-} from "@radix-ui/react-icons";
-import { useInfiniteQuery } from "@tanstack/react-query";
-import Link from "next/link";
-import { useRouter } from "next/navigation";
-import React from "react";
-import { VisibilityObserver } from "reactjs-visibility";
-
-interface IDashboardPageProps {
- initialArticles: PaginatedResponse;
-}
-
-const DashboardPage: React.FC = ({ initialArticles }) => {
- const { _t } = useTranslation();
- const apiRepo = new ArticleApiRepository();
- const router = useRouter();
-
- const { data, fetchNextPage, hasNextPage, refetch } = useInfiniteQuery<
- PaginatedResponse
- >({
- queryKey: ["dashboard-articles"],
- initialData: {
- pageParams: [initialArticles?.meta?.current_page || 1],
- pages: [initialArticles],
- },
- initialPageParam: initialArticles?.meta?.current_page || 1,
- refetchOnMount: true,
- getNextPageParam: (lastPage, allPages) => {
- return allPages.length < lastPage.meta.last_page
- ? lastPage.meta.current_page + 1
- : null;
- },
- queryFn: async ({ pageParam }) => {
- return apiRepo.getMyArticles({
- page: pageParam as number,
- limit: 10,
- });
- },
- });
-
- return (
- <>
- {/* */}
-
- {/*
{JSON.stringify(initialArticles, null, 2)} */}
- {data?.pages.map((page) => {
- return page.data.map((article) => (
-
-
-
{article.id}
-
- {article.title}
-
- {article.is_published && (
-
- {_t("Published on")}{" "}
- {relativeTime(new Date(article.published_at))}
-
- )}
-
-
-
-
- {!article.is_published && (
-
- đ§ {_t("Draft")}
-
- )}
-
-
-
-
{article?.comments_count || 0}
-
-
-
-
-
{article?.votes?.score || 0}
-
-
-
-
-
- ));
- })}
-
- {hasNextPage && (
-
{
- if (isVisible) {
- fetchNextPage();
- }
- }}
- options={{ rootMargin: "200px" }}
- >
-
-
-
-
- )}
-
- >
- );
-};
-
-export default DashboardPage;
-// --
diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx
index 65e422b..8140c47 100644
--- a/src/app/dashboard/page.tsx
+++ b/src/app/dashboard/page.tsx
@@ -1,128 +1,15 @@
-import { Metadata } from "next";
-import React, { Suspense } from "react";
-import DashboardPage from "./page.client";
-import { ArticleApiRepository } from "@/http/repositories/article.repository";
-import { cookieHeaders } from "@/utils/ssr-user";
-import { Paper, Text } from "@mantine/core";
-import _t from "@/i18n/_t";
-import Image from "next/image";
-import Link from "next/link";
-import { revalidateTag, unstable_cache, unstable_noStore } from "next/cache";
-
-export const dynamic = "force-dynamic";
-export const revalidate = 1;
-
-export const metadata: Metadata = {
- title: "Dashboard",
-};
-
-const getArticles = unstable_cache(
- async (headers: any) => {
- // const api = await fetch(
- // process.env.NEXT_PUBLIC_API_URL + "/api/articles/mine",
- // {
- // method: "GET",
- // headers,
- // cache: "no-store",
- // }
- // );
- // return api.json();
- const apiRepo = new ArticleApiRepository();
- const articles = await apiRepo.getMyArticles({ limit: 10 }, headers);
- return articles.data;
- },
- ["dashboard-articles"],
- { tags: ["dashboard-articles"] }
-);
-
-const Dashboard = async () => {
- // const apiRepo = new ArticleApiRepository();
- // const articles = await apiRepo.getMyArticles({ limit: 10 }, cookieHeaders());
- // const articles = await getArticles(cookieHeaders());
-
- const api = await fetch(
- process.env.NEXT_PUBLIC_API_URL + "/api/articles/mine",
- {
- method: "GET",
- headers: cookieHeaders() as any,
- cache: "no-store",
- }
- );
- const articles = await api.json();
-
- const refetch = async () => {
- "use server";
- revalidateTag("dashboard-articles");
- };
+import ArticleList from "./_components/ArticleList";
+import MatrixReport from "./_components/MatrixReport";
+// import ViewsChart from "./_components/ViewsChart";
+const page = () => {
return (
-
-
{_t("States")}
-
-
- {_t("Total posts")}
- {articles?.meta?.total || 0}
-
-
- {_t("Total post reactions")}
- {articles?.meta?.counts?.reactions || 0}
-
-
- {_t("Total post comments")}
- {articles?.meta?.counts.comments || 0}
-
-
- {_t("Total post bookmarks")}
- {articles?.meta?.counts.comments || 0}
-
-
-
-
-
- {!Boolean(articles?.meta?.total) && (
-
-
-
-
- {_t("You didn't write any article yet")}
-
-
-
-
-
-
{_t("New diary")}
-
-
-
- )}
-
-
Loading....}>
-
-
-
+ <>
+ {/* */}
+
+
+ >
);
};
-export default Dashboard;
+export default page;
diff --git a/src/app/dashboard/personal-access-token/_components/PersonalAccessTokenForm.tsx b/src/app/dashboard/personal-access-token/_components/PersonalAccessTokenForm.tsx
deleted file mode 100644
index 8465b10..0000000
--- a/src/app/dashboard/personal-access-token/_components/PersonalAccessTokenForm.tsx
+++ /dev/null
@@ -1,74 +0,0 @@
-import { PersonalAccessTokenApiRepository } from "@/http/repositories/personal-access-token.repository";
-import { useTranslation } from "@/i18n/use-translation";
-import { yupResolver } from "@hookform/resolvers/yup";
-import { Button, Divider, Input, Text } from "@mantine/core";
-import { useMutation } from "@tanstack/react-query";
-import React, { useState } from "react";
-import * as Yup from "yup";
-import { SubmitHandler, useForm } from "react-hook-form";
-import { ErrorMessage } from "@hookform/error-message";
-
-interface Prop {
- onSave: () => void;
-}
-const PersonalAccessTokenForm: React.FC = ({ onSave }) => {
- const { _t } = useTranslation();
- const [createdToken, setCreatedToken] = useState("");
- const api = new PersonalAccessTokenApiRepository();
-
- const createTokenMutation = useMutation({
- mutationFn: (payload: { name: string }) => {
- return api.createToken(payload);
- },
- onSuccess(data) {
- setCreatedToken(data.token);
- onSave();
- },
- });
-
- const form = useForm({
- resolver: yupResolver(formValidationSchema),
- });
-
- const handleOnSubmit: SubmitHandler = (payload) => {
- createTokenMutation.mutate(payload);
- };
-
- return (
-
- );
-};
-
-export default PersonalAccessTokenForm;
-
-const formValidationSchema = Yup.object().shape({
- name: Yup.string().required().min(5).max(255).label("Name"),
-});
-
-type IForm = Yup.InferType;
diff --git a/src/app/dashboard/personal-access-token/page.tsx b/src/app/dashboard/personal-access-token/page.tsx
deleted file mode 100644
index fe5e569..0000000
--- a/src/app/dashboard/personal-access-token/page.tsx
+++ /dev/null
@@ -1,167 +0,0 @@
-"use client";
-import { PersonalAccessTokenApiRepository } from "@/http/repositories/personal-access-token.repository";
-import { useTranslation } from "@/i18n/use-translation";
-import { relativeTime } from "@/utils/relativeTime";
-import { Button, Modal, Paper, Text } from "@mantine/core";
-import { openConfirmModal } from "@mantine/modals";
-import { ExternalLinkIcon } from "@radix-ui/react-icons";
-import { useMutation, useQuery } from "@tanstack/react-query";
-import Image from "next/image";
-import React from "react";
-import PersonalAccessTokenForm from "./_components/PersonalAccessTokenForm";
-import { useDisclosure } from "@mantine/hooks";
-
-const PersonalAccessTokenPage = () => {
- const [formModalOpened, formModalHandler] = useDisclosure(false);
-
- const { _t } = useTranslation();
- const api = new PersonalAccessTokenApiRepository();
- const tokenListQuery = useQuery({
- queryKey: ["personal-access-token"],
- queryFn: async () => {
- const { data } = await api.getMyTokens();
- return data;
- },
- });
- const deleteTokenMutation = useMutation({
- mutationFn: async (tokenId: string) => {
- const { data } = await api.deleteToken(tokenId);
- return data;
- },
- onSuccess() {
- tokenListQuery.refetch();
- },
- });
-
- return (
-
-
-
- {tokenListQuery.isFetched && tokenListQuery.data?.length == 0 && (
-
-
-
-
- {_t("You didn't create any personal access token yet")}
-
-
-
-
- )}
-
-
- {tokenListQuery.data?.map((token) => (
-
- {token.name}
-
- {_t("Created At")}:{" "}
- {relativeTime(new Date(token.created_at!))}
-
-
- {token.last_used_at && (
-
- {_t("Last used at")}:{" "}
- {relativeTime(new Date(token.last_used_at!))}
-
- )}
-
-
-
- ))}
-
-
-
-
-
-
- );
-};
-
-export default PersonalAccessTokenPage;
diff --git a/src/app/dashboard/sessions/page.tsx b/src/app/dashboard/sessions/page.tsx
new file mode 100644
index 0000000..9ab35ba
--- /dev/null
+++ b/src/app/dashboard/sessions/page.tsx
@@ -0,0 +1,101 @@
+"use client";
+
+import * as sessionActions from "@/backend/services/session.actions";
+import { useAppConfirm } from "@/components/app-confirm";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { useTranslation } from "@/i18n/use-translation";
+import { formattedTime } from "@/lib/utils";
+import { useSession } from "@/store/session.atom";
+import { useQuery } from "@tanstack/react-query";
+import {
+ BadgeIcon,
+ ClockIcon,
+ ComputerIcon,
+ Loader,
+ LogOut,
+} from "lucide-react";
+
+const SessionsPage = () => {
+ const authSession = useSession();
+ const appConfirm = useAppConfirm();
+ const { _t } = useTranslation();
+
+ const sessionQuery = useQuery({
+ queryKey: ["mySessions"],
+ queryFn: () => sessionActions.mySessions(),
+ });
+ return (
+
+
{_t("Login Sessions")}
+
+ {_t(
+ "These are the login sessions of your account. You can use this to revoke access to your account"
+ )}
+ .
+
+ {/*
{JSON.stringify(sessionQuery.data, null, 2)} */}
+
+
+ {sessionQuery.isFetching && (
+
+
+
+ )}
+
+ {sessionQuery.data?.map((session) => (
+
+
+
+
+ {session.device}
+
+
+
+
+ Last active {formattedTime(session.last_action_at!)}
+
+
+
+
IP: {session.ip}
+
+ {authSession?.session?.id == session.id && (
+
+ Current Session
+
+ )}
+
+
+
+ ))}
+
+
+ );
+};
+
+export default SessionsPage;
diff --git a/src/app/dashboard/settings/_components/SettingGeneralTab.tsx b/src/app/dashboard/settings/_components/SettingGeneralTab.tsx
deleted file mode 100644
index 31c4b1a..0000000
--- a/src/app/dashboard/settings/_components/SettingGeneralTab.tsx
+++ /dev/null
@@ -1,213 +0,0 @@
-import {
- ProfileApiRepository,
- UpdateProfilePayload,
-} from "@/http/repositories/profile.repository";
-import { useTranslation } from "@/i18n/use-translation";
-import { userAtom } from "@/store/user.atom";
-import AppAxiosException from "@/utils/AppAxiosException";
-import { ErrorMessage } from "@hookform/error-message";
-import { yupResolver } from "@hookform/resolvers/yup";
-import { Alert, Button, Input, Textarea } from "@mantine/core";
-import { useDebouncedCallback } from "@mantine/hooks";
-import { showNotification } from "@mantine/notifications";
-import { useMutation } from "@tanstack/react-query";
-import { useAtomValue } from "jotai";
-import React, { useState } from "react";
-import { SubmitHandler, useForm } from "react-hook-form";
-import * as Yup from "yup";
-
-const SettingGeneralTab = () => {
- const authUser = useAtomValue(userAtom);
- const { _t } = useTranslation();
-
- const api = new ProfileApiRepository();
- const updateProfileMutation = useMutation({
- mutationFn: (payload: UpdateProfilePayload) => {
- return api.updateProfile(payload);
- },
- onSuccess() {
- showNotification({
- title: "Updated successfully",
- message: "",
- });
- },
- onError(error: AppAxiosException) {
- const msg = error.response?.data?.message || "Failed to update article";
- },
- });
-
- const {
- register,
- handleSubmit,
- formState: { errors },
- setValue,
- watch,
- } = useForm({
- defaultValues: {
- name: authUser?.name || "",
- email: authUser?.email || "",
- username: authUser?.username || "",
- education: authUser?.education || "",
- designation: authUser?.designation || "",
- website_url: authUser?.website_url || "",
- location: authUser?.location || "",
- bio: authUser?.bio || "",
- },
- resolver: yupResolver(SettingsFormValidationSchema),
- });
-
- const handleOnChangeUsernameDebounce = useDebouncedCallback(
- async (username: string) => {
- await api
- .getUniqueUsername(username)
- .then((res) => {
- if (res?.data?.username) {
- setValue("username", res?.data?.username);
- }
- })
- .catch((err) => {
- console.log(err);
- });
- },
- 2000
- );
-
- const handleOnSubmit: SubmitHandler = (data) => {
- updateProfileMutation.mutate(data);
- };
-
- return (
-
- );
-};
-
-export default SettingGeneralTab;
-
-const SettingsFormValidationSchema = Yup.object().shape({
- name: Yup.string().required().max(50).label("Name"),
-
- username: Yup.string()
- .optional()
- .max(50, "Username cannot exceed 255 characters")
- .label("Username"),
-
- email: Yup.string()
- .email()
- .optional()
- .max(50, "Email cannot exceed 255 characters")
- .label("Email"),
-
- education: Yup.string()
- .optional()
- .max(50, "Education cannot exceed 255 characters")
- .label("Education"),
-
- designation: Yup.string()
- .optional()
- .max(255, "Designation cannot exceed 255 characters")
- .label("Designation"),
-
- website_url: Yup.string()
- .optional()
- .max(255, "Website url cannot exceed 255 characters")
- .url()
- .label("Website url"),
-
- location: Yup.string()
- .optional()
- .max(255, "Location cannot exceed 255 characters")
- .label("Location"),
-
- bio: Yup.string()
- .optional()
- .max(255, "Bio cannot exceed 255 characters")
- .label("Bio"),
-});
-
-type ISettingsForm = Yup.InferType;
diff --git a/src/app/dashboard/settings/_components/SettingProfileReadmeTab.tsx b/src/app/dashboard/settings/_components/SettingProfileReadmeTab.tsx
deleted file mode 100644
index f87c012..0000000
--- a/src/app/dashboard/settings/_components/SettingProfileReadmeTab.tsx
+++ /dev/null
@@ -1,189 +0,0 @@
-import {
- ProfileApiRepository,
- UpdateProfilePayload,
-} from "@/http/repositories/profile.repository";
-import { useTranslation } from "@/i18n/use-translation";
-import AppAxiosException from "@/utils/AppAxiosException";
-import { showNotification } from "@mantine/notifications";
-import { useMutation } from "@tanstack/react-query";
-import { useAtomValue } from "jotai";
-import * as Yup from "yup";
-import React, { useState } from "react";
-import { SubmitHandler, useForm } from "react-hook-form";
-import { userAtom } from "@/store/user.atom";
-import { yupResolver } from "@hookform/resolvers/yup";
-import {
- RiBold,
- RiHeading,
- RiImageAddFill,
- RiItalic,
- RiLink,
- RiListOrdered,
- RiListUnordered,
-} from "react-icons/ri";
-import { markdownToHtml } from "@/utils/markdoc-parser";
-import {
- boldCommand,
- headingLevel2Command,
- headingLevel3Command,
- imageCommand,
- italicCommand,
- linkCommand,
- orderedListCommand,
- unorderedListCommand,
- useTextAreaMarkdownEditor,
-} from "react-mde";
-import { Button } from "@mantine/core";
-
-const SettingProfileReadmeTab = () => {
- const authUser = useAtomValue(userAtom);
- const { _t } = useTranslation();
- const [errorMsg, setErrorMsg] = useState(null);
- const [editorMode, selectEditorMode] = React.useState<"write" | "preview">(
- "write"
- );
-
- const api = new ProfileApiRepository();
- const updateProfileMutation = useMutation({
- mutationFn: (payload: UpdateProfilePayload) => {
- return api.updateProfile(payload);
- },
- onSuccess() {
- showNotification({
- title: "Updated successfully",
- message: "",
- });
- },
- onError(error: AppAxiosException) {
- const msg = error.response?.data?.message || "Failed to update article";
- setErrorMsg(msg);
- },
- });
-
- const { ref: editorTextareaRef, commandController } =
- useTextAreaMarkdownEditor({
- commandMap: {
- h2: headingLevel2Command,
- h3: headingLevel3Command,
- bold: boldCommand,
- italic: italicCommand,
- image: imageCommand,
- link: linkCommand,
- ul: unorderedListCommand,
- ol: orderedListCommand,
- },
- });
-
- const { handleSubmit, setValue, watch } = useForm({
- defaultValues: {
- profile_readme: authUser?.profile_readme || "",
- },
- resolver: yupResolver(SettingsFormValidationSchema),
- });
-
- const handleOnSubmit: SubmitHandler = (payload) => {
- updateProfileMutation.mutate(payload);
- };
-
- return (
-
- );
-};
-
-export default SettingProfileReadmeTab;
-
-const SettingsFormValidationSchema = Yup.object().shape({
- profile_readme: Yup.string().optional().label("Name"),
-});
-
-type ISettingsForm = Yup.InferType;
-
-interface Prop extends React.ButtonHTMLAttributes {
- Icon: React.ReactNode;
- isDisabled?: boolean;
-}
-const EditorCommandButton: React.FC = ({ isDisabled, ...props }) => {
- return (
-
- );
-};
diff --git a/src/app/dashboard/settings/_components/SettingSocialTab.tsx b/src/app/dashboard/settings/_components/SettingSocialTab.tsx
deleted file mode 100644
index a62dd3f..0000000
--- a/src/app/dashboard/settings/_components/SettingSocialTab.tsx
+++ /dev/null
@@ -1,110 +0,0 @@
-import {
- ProfileApiRepository,
- UpdateProfilePayload,
-} from "@/http/repositories/profile.repository";
-import { useTranslation } from "@/i18n/use-translation";
-import { userAtom } from "@/store/user.atom";
-import AppAxiosException from "@/utils/AppAxiosException";
-import { ErrorMessage } from "@hookform/error-message";
-import { yupResolver } from "@hookform/resolvers/yup";
-import { Button, Input } from "@mantine/core";
-import { showNotification } from "@mantine/notifications";
-import { Link1Icon } from "@radix-ui/react-icons";
-import { useMutation } from "@tanstack/react-query";
-import { useAtomValue } from "jotai";
-import { useState } from "react";
-import { SubmitHandler, useForm } from "react-hook-form";
-import * as yup from "yup";
-
-const SettingSocialTab = () => {
- const authUser = useAtomValue(userAtom);
- const [errorMsg, setErrorMsg] = useState(null);
-
- const { _t } = useTranslation();
- const {
- register,
- handleSubmit,
- formState: { errors },
- } = useForm({
- resolver: yupResolver(formSchema),
- defaultValues: {
- github: authUser?.social_links?.github || "",
- facebook: authUser?.social_links?.facebook || "",
- stackOverflow: authUser?.social_links?.stackOverflow || "",
- medium: authUser?.social_links?.medium || "",
- linkedin: authUser?.social_links?.linkedin || "",
- twitter: authUser?.social_links?.twitter || "",
- instagram: authUser?.social_links?.instagram || "",
- behance: authUser?.social_links?.behance || "",
- dribbble: authUser?.social_links?.dribbble || "",
- twitch: authUser?.social_links?.twitch || "",
- youtube: authUser?.social_links?.youtube || "",
- },
- });
-
- const api = new ProfileApiRepository();
- const updateProfileMutation = useMutation({
- mutationFn: (payload: UpdateProfilePayload) => {
- return api.updateProfile(payload);
- },
- onSuccess() {
- showNotification({
- title: "Updated successfully",
- message: "",
- });
- },
- onError(error: AppAxiosException) {
- const msg = error.response?.data?.message || "Failed to update article";
- setErrorMsg(msg);
- },
- });
-
- const handleOnSubmit: SubmitHandler = (payload) => {
- updateProfileMutation.mutate({ social_links: payload as any });
- };
-
- return (
-
- );
-};
-
-export default SettingSocialTab;
-
-const formSchema = yup.object().shape({
- github: yup.string().optional().nullable().url(),
- facebook: yup.string().optional().nullable().url(),
- stackOverflow: yup.string().optional().nullable().url(),
- medium: yup.string().optional().nullable().url(),
- linkedin: yup.string().optional().nullable().url(),
- twitter: yup.string().optional().nullable().url(),
- instagram: yup.string().optional().nullable().url(),
- behance: yup.string().optional().nullable().url(),
- dribbble: yup.string().optional().nullable().url(),
- twitch: yup.string().optional().nullable().url(),
- youtube: yup.string().optional().nullable().url(),
-});
-type ISettingsForm = yup.InferType;
diff --git a/src/app/dashboard/settings/page.tsx b/src/app/dashboard/settings/page.tsx
index 5a2df50..3df180b 100644
--- a/src/app/dashboard/settings/page.tsx
+++ b/src/app/dashboard/settings/page.tsx
@@ -1,33 +1,25 @@
-"use client";
-
-import { useTranslation } from "@/i18n/use-translation";
-import { Tabs } from "@mantine/core";
-import SettingGeneralTab from "./_components/SettingGeneralTab";
-import SettingProfileTab from "./_components/SettingProfileReadmeTab";
-import SettingSocialTab from "./_components/SettingSocialTab";
-import SettingProfileReadmeTab from "./_components/SettingProfileReadmeTab";
-
-const SettingPage = () => {
- const { _t } = useTranslation();
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import _t from "@/i18n/_t";
+const SettingsPage = () => {
return (
-
-
- {_t("General")}
- {_t("Social")}
- {_t("Profile Readme")}
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+ {_t("General")}
+ {_t("Social")}
+
+ {_t("Profile Readme")}
+
+
+ Change your password here.
+ Change your password here.
+
+ Change your profile readme here.
+
+
+
);
};
-export default SettingPage;
+export default SettingsPage;
diff --git a/src/app/design-system/page.tsx b/src/app/design-system/page.tsx
new file mode 100644
index 0000000..279efd5
--- /dev/null
+++ b/src/app/design-system/page.tsx
@@ -0,0 +1,149 @@
+import BaseLayout from "@/components/layout/BaseLayout";
+import { Button } from "@/components/ui/button";
+import React from "react";
+
+const DesignSystemPage = () => {
+ return (
+
+
+ {/* Colors Section */}
+
+ Colors
+
+
+
Primary Background
+
+ Primary Foreground
+
+
+
+
Secondary Background
+
+ Secondary Foreground
+
+
+
+
Accent Background
+
+ Accent Foreground
+
+
+
+
Muted Background
+
+ Muted Foreground
+
+
+
+
Destructive
+
+ Destructive Foreground
+
+
+
+
+
+
+ {/* Typography Section */}
+
+ Typography
+
+
+
Font Families
+
+
Sans Font (Geist Sans)
+
Mono Font (Geist Mono)
+
āĻā§āĻšāĻŋāύā§āϰ āĻŦāĻžāĻāϞāĻž
+
+
+
+
Text Sizes
+
+
Extra Small (text-xs)
+
Small (text-sm)
+
Base (text-base)
+
Large (text-lg)
+
Extra Large (text-xl)
+
2XL (text-2xl)
+
+
+
+
+
+ {/* Border Radius Section */}
+
+
+ {/* Shadows Section */}
+
+
+ {/* Interactive States */}
+
+ Interactive States
+
+
+
Buttons
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default DesignSystemPage;
diff --git a/src/app/error.tsx b/src/app/error.tsx
index af5cadb..703d182 100644
--- a/src/app/error.tsx
+++ b/src/app/error.tsx
@@ -1,6 +1,6 @@
"use client";
-import ErrorPage from "@/components/ErrorPage";
-import BaseLayout from "@/components/layout/BaseLayout";
+
+import Link from "next/link";
import React from "react";
interface ErrorPageProps {
@@ -10,9 +10,16 @@ interface ErrorPageProps {
const _ErrorPage: React.FC = (props) => {
return (
-
-
-
+
+
+

+
+ {props.error.message}
+
+
{JSON.stringify(props.error, null, 2)}
+
{"Go back to home"}
+
+
);
};
diff --git a/src/app/experiment/page.tsx b/src/app/experiment/page.tsx
deleted file mode 100644
index 990b258..0000000
--- a/src/app/experiment/page.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-"use client";
-
-import UnsplashImageGallery from "@/components/UnsplashImageGallery";
-import BaseLayout from "@/components/layout/BaseLayout";
-import { IServerFile } from "@/http/models/AppImage.model";
-import { Button, Input, Modal, Title } from "@mantine/core";
-import { useDisclosure } from "@mantine/hooks";
-
-const ExperimentPage = () => {
- const [opened, { open, close }] = useDisclosure(false);
-
- return (
-
-
- {
- alert(JSON.stringify(image));
- }}
- />
-
-
-
- );
-};
-
-export default ExperimentPage;
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 7f65c26..e081adc 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,32 +1,44 @@
import type { Metadata } from "next";
+import "../styles/app.css";
-import "@mantine/core/styles.css";
-import "@mantine/notifications/styles.css";
-import "../styles/app.scss";
-
-import { ColorSchemeScript } from "@mantine/core";
+import * as sessionActions from "@/backend/services/session.actions";
+import CommonProviders from "@/components/providers/CommonProviders";
+import I18nProvider from "@/components/providers/I18nProvider";
+import { fontKohinoorBanglaRegular } from "@/lib/fonts";
+import { cookies } from "next/headers";
+import React, { PropsWithChildren } from "react";
export const metadata: Metadata = {
- title: "Techdiary - %s",
- openGraph: { title: "Techdiary" },
+ title: {
+ default: "TechDiary",
+ template: "%s | TechDiary",
+ },
+ applicationName: "TechDiary",
+ referrer: "origin-when-cross-origin",
+ keywords: ["TechDiary", "āĻā§āĻāĻĄāĻžā§ā§āϰāĻŋ"],
icons: { icon: "/favicon.png" },
+ description: "Homepage of TechDiary",
+ metadataBase: new URL("https://www.techdiary.dev"),
+ openGraph: {
+ title: "TechDiary - āĻā§āĻāĻĄāĻžā§ā§āϰāĻŋ",
+ description: "āĻāĻŋāύā§āϤāĻž, āϏāĻŽāϏā§āϝāĻž, āϏāĻŽāĻžāϧāĻžāύ",
+ url: "https://www.techdiary.dev",
+ siteName: "TechDiary",
+ locale: "bn_BD",
+ type: "website",
+ images: ["https://www.techdiary.dev/og.png"],
+ },
};
-import AppProvider from "@/providers/AppProvider";
-import { fontKohinoorBanglaRegular } from "@/utils/fonts";
-import React, { PropsWithChildren } from "react";
-import RootWrapper from "../providers/RootWrapper";
-
const RootLayout: React.FC = async ({ children }) => {
+ const _cookies = await cookies();
+ const session = await sessionActions.getSession();
return (
-
-
-
-
+
-
- {children}
-
+
+ {children}
+
);
diff --git a/src/app/loading.tsx b/src/app/loading.tsx
deleted file mode 100644
index 1dec729..0000000
--- a/src/app/loading.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-import BaseLayout from "@/components/layout/BaseLayout";
-import { Loader } from "@mantine/core";
-import React from "react";
-
-const GlobalLoadingPage = () => {
- return (
-
-
-
-
-
- );
-};
-
-export default GlobalLoadingPage;
diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx
new file mode 100644
index 0000000..2e983e4
--- /dev/null
+++ b/src/app/login/page.tsx
@@ -0,0 +1,27 @@
+import BaseLayout from "@/components/layout/BaseLayout";
+import SocialLoginCard from "@/components/SocialLoginCard";
+import SocialLinksWidget from "@/components/widgets/SocialLinksWidget";
+import _t from "@/i18n/_t";
+import React from "react";
+
+const page = async () => {
+ return (
+
+
+
+
+ {_t("Join Techdiary community")}
+
+
+ {_t(
+ "A community of 5000+ incredible developers is called Techdiary Community"
+ )}
+
+
+
+
+
+ );
+};
+
+export default page;
diff --git a/src/app/sitemaps/articles/sitemap.ts b/src/app/sitemaps/articles/sitemap.ts
deleted file mode 100644
index 19e812b..0000000
--- a/src/app/sitemaps/articles/sitemap.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import { http } from "@/http/http.client";
-import type { MetadataRoute } from "next";
-
-export default async function sitemap(): Promise {
- const { data } = await http.get("api/articles", {
- params: {
- limit: 100,
- page: -1,
- },
- });
-
- return data.data.map((article: any) => ({
- url: article.url,
- lastModified: new Date(article.created_at),
- changeFrequency: "weekly",
- priority: 0.5,
- }));
-}
diff --git a/src/app/sitemaps/tags/sitemap.ts b/src/app/sitemaps/tags/sitemap.ts
deleted file mode 100644
index cb271a6..0000000
--- a/src/app/sitemaps/tags/sitemap.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import { http } from "@/http/http.client";
-import { PaginatedResponse } from "@/http/models/PaginatedResponse.model";
-import type { MetadataRoute } from "next";
-
-export default async function sitemap(): Promise {
- const { data } = await http.get>("api/tags", {
- params: { limit: -1 },
- });
-
- return data.data.map((tag) => ({
- url: `https://techdiary.dev/tags/${tag?.name
- ?.toLowerCase()
- .replace(/\&/g, "")
- ?.split(" ")
- ?.join("-")}`,
- lastModified: new Date(),
- changeFrequency: "weekly",
- priority: 0.5,
- }));
-}
diff --git a/src/app/sitemaps/users/sitemap.ts b/src/app/sitemaps/users/sitemap.ts
deleted file mode 100644
index 18a5494..0000000
--- a/src/app/sitemaps/users/sitemap.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { http } from "@/http/http.client";
-import { PaginatedResponse } from "@/http/models/PaginatedResponse.model";
-import { UserReference } from "@/http/models/User.model";
-import type { MetadataRoute } from "next";
-
-export default async function sitemap(): Promise {
- const { data } = await http.get>(
- "api/profile/list",
- {
- params: {
- limit: -1,
- page: 1,
- },
- }
- );
-
- return data.data.map((user) => ({
- url: `https://techdiary.dev/@${user.username}`,
- lastModified: new Date(user.joined),
- changeFrequency: "weekly",
- priority: 0.5,
- }));
-}
diff --git a/src/app/test/page.tsx b/src/app/test/page.tsx
new file mode 100644
index 0000000..38a11b5
--- /dev/null
+++ b/src/app/test/page.tsx
@@ -0,0 +1,15 @@
+"use client";
+
+import { TagInput } from "@/components/ui/tags-input";
+import React from "react";
+
+const page = () => {
+ const [tags, setTags] = React.useState([]);
+ return (
+
+
+
+ );
+};
+
+export default page;
diff --git a/src/backend/database.ts b/src/backend/database.ts
new file mode 100644
index 0000000..3286340
--- /dev/null
+++ b/src/backend/database.ts
@@ -0,0 +1,5 @@
+import "dotenv/config";
+import { drizzle } from "drizzle-orm/node-postgres";
+import * as schema from "./schemas/schemas";
+import { Client as PgClient } from "pg";
+// export const db = drizzle(process.env.DATABASE_URL!, { schema });
diff --git a/src/backend/models/domain-models.ts b/src/backend/models/domain-models.ts
new file mode 100644
index 0000000..9bf2291
--- /dev/null
+++ b/src/backend/models/domain-models.ts
@@ -0,0 +1,69 @@
+export interface User {
+ id: string;
+ name: string;
+ username: string;
+ email: string;
+ profile_photo: string;
+ education: string;
+ designation: string;
+ bio: string;
+ website_url: string;
+ location: string;
+ social_links: any;
+ profile_readme: string;
+ skills: string;
+ created_at: Date;
+ updated_at: Date;
+}
+
+export interface UserSocial {
+ id: number;
+ service: string;
+ service_uid: string;
+
+ user_id: string;
+ created_at: Date;
+ updated_at: Date;
+}
+
+export interface UserSession {
+ id: string;
+ user_id: string;
+ token: string;
+ device?: string;
+ device_type?: string;
+ ip?: string;
+ last_action_at?: Date;
+ created_at: Date;
+}
+
+export interface IServerFile {
+ key: string;
+ provider: "cloudinary" | "direct";
+}
+
+export interface ArticleMetadata {
+ seo: {
+ title?: string;
+ description?: string;
+ keywords?: string[];
+ canonical_url?: string;
+ };
+}
+
+export interface Article {
+ id: string;
+ title: string;
+ handle: string;
+ excerpt?: string | null;
+ body?: string | null;
+ cover_image?: IServerFile | null;
+ is_published: boolean;
+ published_at?: Date | null;
+ approved_at?: Date | null;
+ user?: User | null;
+ metadata?: ArticleMetadata | null;
+ author_id: string;
+ created_at: Date;
+ updated_at: Date;
+}
diff --git a/src/backend/models/models.ts b/src/backend/models/models.ts
new file mode 100644
index 0000000..f2769b6
--- /dev/null
+++ b/src/backend/models/models.ts
@@ -0,0 +1,5 @@
+export enum DatabaseTableName {
+ users = "users",
+ user_socials = "user_socials",
+ user_sessions = "user_sessions",
+}
diff --git a/src/backend/persistence-repositories.ts b/src/backend/persistence-repositories.ts
new file mode 100644
index 0000000..e8a2865
--- /dev/null
+++ b/src/backend/persistence-repositories.ts
@@ -0,0 +1,25 @@
+import { Article, User, UserSession, UserSocial } from "./models/domain-models";
+import { pgClient } from "./persistence/database-drivers/pg.client";
+import { PersistentRepository } from "./persistence/persistence.repository";
+
+export const userRepository = new PersistentRepository("users", pgClient);
+export const articleRepository = new PersistentRepository(
+ "articles",
+ pgClient
+);
+
+export const userSocialRepository = new PersistentRepository(
+ "user_socials",
+ pgClient
+);
+export const userSessionRepository = new PersistentRepository(
+ "user_sessions",
+ pgClient
+);
+
+export const persistenceRepository = {
+ user: userRepository,
+ userSocial: userSocialRepository,
+ userSession: userSessionRepository,
+ article: articleRepository,
+};
diff --git a/src/backend/persistence/database-drivers/PersistenceDriverError.ts b/src/backend/persistence/database-drivers/PersistenceDriverError.ts
new file mode 100644
index 0000000..1235669
--- /dev/null
+++ b/src/backend/persistence/database-drivers/PersistenceDriverError.ts
@@ -0,0 +1,9 @@
+export class PersistenceDriverError extends Error {
+ constructor(
+ message: string,
+ public originalError?: any
+ ) {
+ super(message);
+ this.name = "PersistenceDriverError";
+ }
+}
diff --git a/src/backend/persistence/database-drivers/pg.client.ts b/src/backend/persistence/database-drivers/pg.client.ts
new file mode 100644
index 0000000..98d5e4f
--- /dev/null
+++ b/src/backend/persistence/database-drivers/pg.client.ts
@@ -0,0 +1,103 @@
+import { Pool } from "pg";
+import { IPersistentDriver } from "../persistence-contracts";
+import { buildSafeQuery } from "../persistence-utils";
+import { PersistenceDriverError } from "./PersistenceDriverError";
+import { env } from "@/env";
+
+declare global {
+ var pgClient: IPersistentDriver | undefined;
+}
+
+export class PgDatabaseClient implements IPersistentDriver {
+ private static instance: PgDatabaseClient;
+
+ private pool: Pool;
+
+ private constructor() {
+ try {
+ console.log("Creating new Postgres client");
+ this.pool = new Pool({
+ connectionString: env.DATABASE_URL,
+ });
+
+ // Test the connection
+ this.pool.on("error", (err) => {
+ console.error("Unexpected error on idle client", err);
+ throw new PersistenceDriverError("Database pool error", err);
+ });
+ } catch (error) {
+ throw new PersistenceDriverError(
+ "Failed to initialize database connection",
+ error
+ );
+ }
+ }
+
+ public static getInstance(): PgDatabaseClient {
+ if (!PgDatabaseClient.instance) {
+ PgDatabaseClient.instance = new PgDatabaseClient();
+ }
+ return PgDatabaseClient.instance;
+ }
+
+ /**
+ * Executes a raw SQL query with the provided values.
+ * @param sql
+ * @param values
+ */
+ async executeSQL(sql: string, values: Array): Promise<{ rows: any[] }> {
+ try {
+ if (!sql) {
+ throw new PersistenceDriverError("SQL query is required");
+ }
+
+ console.log({
+ sql,
+ values,
+ });
+
+ const safeSql = buildSafeQuery(sql, values);
+ const client = await this.pool.connect();
+
+ try {
+ const result = await client.query(safeSql);
+ return {
+ rows: result.rows as any[],
+ };
+ } catch (error) {
+ throw new PersistenceDriverError(
+ `Query execution failed: ${sql}`,
+ error
+ );
+ } finally {
+ client.release();
+ }
+ } catch (error) {
+ if (error instanceof PersistenceDriverError) {
+ throw error;
+ }
+ throw new PersistenceDriverError("Database operation failed", error);
+ }
+ }
+
+ /**
+ * Closes the database connection pool
+ */
+ async close(): Promise {
+ try {
+ await this.pool.end();
+ } catch (error) {
+ throw new PersistenceDriverError(
+ "Failed to close database connection",
+ error
+ );
+ }
+ }
+}
+
+// Initialize global database connection
+if (!globalThis.pgClient) {
+ globalThis.pgClient = PgDatabaseClient.getInstance();
+}
+
+export const pgClient = globalThis.pgClient;
diff --git a/src/backend/persistence/persistence-contracts.ts b/src/backend/persistence/persistence-contracts.ts
new file mode 100644
index 0000000..af46ef2
--- /dev/null
+++ b/src/backend/persistence/persistence-contracts.ts
@@ -0,0 +1,112 @@
+interface PaginationMeta {
+ totalCount: number;
+ currentPage: number;
+ hasNextPage: boolean;
+ totalPages: number;
+}
+
+export class AppPaginationResponseDto {
+ nodes: T[];
+ meta: PaginationMeta;
+
+ constructor(data: any, meta: PaginationMeta) {
+ this.meta = meta;
+ this.nodes = data;
+ }
+}
+
+//------------------------------------
+// Pagination
+//------------------------------------
+export enum ISortType {
+ ASC = "ASC",
+ DESC = "DESC",
+}
+
+//------------------------------------
+// Find
+//------------------------------------
+export interface IPersistentCondition {
+ key: keyof T;
+ value: any;
+ operator:
+ | "="
+ | "<"
+ | ">"
+ | "<="
+ | ">="
+ | "<>"
+ | "like"
+ | "ilike"
+ | "in"
+ | "not in";
+}
+export interface IPersistentOrderBy {
+ key: keyof T;
+ direction: "asc" | "desc";
+}
+
+export type SimpleWhere = IPersistentCondition;
+export type CompositeWhere = {
+ OR?: WhereCondition[];
+ AND?: WhereCondition[];
+};
+export type WhereCondition = SimpleWhere | CompositeWhere;
+
+export interface IPersistenceJoin {
+ as: string;
+ joinTo: string;
+ localField: string;
+ foreignField: string;
+ columns: string[];
+}
+
+export interface IPersistentPaginationPayload {
+ where?: WhereCondition;
+ joins?: IPersistenceJoin[];
+ orderBy?: Array>;
+ columns?: Array;
+ limit?: number;
+ offset?: number;
+}
+
+export interface IPersistentQueryPayload {
+ where?: WhereCondition; // No longer allows arrays
+ orderBy?: Array>;
+ columns?: Array;
+}
+
+export interface IPersistentUpdatePayload {
+ where: WhereCondition; // No longer allows arrays
+ data: Partial;
+ columns?: Array;
+}
+
+export interface IPagination {
+ page?: number;
+ limit?: number;
+ where?: WhereCondition; // No longer allows arrays
+ columns?: Array;
+ orderBy?: Array>;
+ joins?: IPersistenceJoin[];
+}
+
+//------------------------------------
+// Driver
+//------------------------------------
+
+export interface IPersistentDriver {
+ executeSQL(
+ sql: string,
+ values: Array
+ ): Promise<{
+ rows: Array;
+ }>;
+}
+
+export enum DatabaseTableName {
+ tenants = "tenants",
+ clients = "clients",
+ users = "users",
+ roles = "roles",
+}
diff --git a/src/backend/persistence/persistence-utils.ts b/src/backend/persistence/persistence-utils.ts
new file mode 100644
index 0000000..69f1060
--- /dev/null
+++ b/src/backend/persistence/persistence-utils.ts
@@ -0,0 +1,317 @@
+import {
+ IPersistenceJoin,
+ IPersistentOrderBy,
+ SimpleWhere,
+ WhereCondition,
+} from "./persistence-contracts";
+
+export const sql = String.raw;
+
+/**
+ * Builds a WHERE clause for SQL queries
+ * @param where Where condition or undefined
+ * @returns Object containing the WHERE clause and values array
+ */
+export const buildWhereClause = (
+ where: WhereCondition | undefined
+): { whereClause: string; values: any[] } => {
+ // If no where clause is provided, return empty
+ if (!where) {
+ return { whereClause: "", values: [] };
+ }
+
+ const values: any[] = [];
+
+ // Process the where condition
+ const result = processWhereCondition(where, values);
+
+ return {
+ whereClause: result,
+ values,
+ };
+};
+
+/**
+ * Process a where condition recursively
+ */
+const processWhereCondition = (
+ where: WhereCondition,
+ values: any[]
+): string => {
+ // Handle composite conditions (AND/OR)
+ if (typeof where === "object") {
+ if ("AND" in where && Array.isArray(where.AND) && where.AND.length > 0) {
+ const conditions = where.AND.map((condition) =>
+ processWhereCondition(condition, values)
+ ).filter(Boolean);
+
+ if (conditions.length === 0) return "";
+ if (conditions.length === 1) return conditions[0];
+
+ return `(${conditions.join(" AND ")})`;
+ }
+
+ if ("OR" in where && Array.isArray(where.OR) && where.OR.length > 0) {
+ const conditions = where.OR.map((condition) =>
+ processWhereCondition(condition, values)
+ ).filter(Boolean);
+
+ if (conditions.length === 0) return "";
+ if (conditions.length === 1) return conditions[0];
+
+ return `(${conditions.join(" OR ")})`;
+ }
+
+ // Handle simple conditions
+ if ("key" in where && "operator" in where) {
+ return processSimpleCondition(where as SimpleWhere, values);
+ }
+ }
+
+ return "";
+};
+
+/**
+ * Process a simple condition
+ */
+const processSimpleCondition = (
+ condition: SimpleWhere,
+ values: any[]
+): string => {
+ const { key, operator, value } = condition;
+
+ if (!key) return ""; // Skip if key is missing
+
+ // Handle arrays for IN and NOT IN operators
+ if ((operator === "in" || operator === "not in") && Array.isArray(value)) {
+ if (value.length === 0) {
+ return operator === "in" ? "FALSE" : "TRUE";
+ }
+
+ const placeholders = value
+ .map(() => `$${values.length + 1}`)
+ .map((placeholder, index) => {
+ values.push(value[index]);
+ return placeholder;
+ })
+ .join(", ");
+
+ return `"${key.toString()}" ${operator} (${placeholders})`;
+ }
+
+ // Handle NULL values
+ if (value === null) {
+ return operator === "="
+ ? `"${key.toString()}" IS NULL`
+ : operator === "<>"
+ ? `"${key.toString()}" IS NOT NULL`
+ : `"${key.toString()}" IS NULL`;
+ }
+
+ // Standard case with non-null value
+ values.push(value);
+ return `"${key.toString()}" ${operator} $${values.length}`;
+};
+
+/**
+ * Builds an ORDER BY clause for SQL queries
+ * @param orderBy Array of order specifications
+ * @returns Formatted ORDER BY clause
+ */
+export const buildOrderByClause = (
+ orderBy?: Array>
+): string => {
+ if (!orderBy || orderBy.length === 0) {
+ return ""; // No order by clause
+ }
+
+ const orderByConditions = orderBy.map(({ key, direction }) => {
+ // Convert column name to snake_case for database compatibility
+ const snakeCaseColumn = toSnakeCase(key.toString());
+
+ // Escape column name to prevent SQL injection
+ const safeColumn = `"${snakeCaseColumn}"`;
+
+ // Validate and normalize order direction
+ const safeDirection = direction?.toLowerCase() === "desc" ? "DESC" : "ASC";
+
+ // Add NULLS LAST for better sorting behavior
+ const nullsOrder = safeDirection === "DESC" ? "NULLS LAST" : "NULLS FIRST";
+
+ return `${safeColumn} ${safeDirection} ${nullsOrder}`;
+ });
+
+ return `ORDER BY ${orderByConditions.join(", ")}`;
+};
+
+export const buildJoinClause = (joins?: Array) => {
+ if (!joins || joins.length === 0) {
+ return {
+ joinConditionClause: "",
+ joinSelectClause: [],
+ };
+ }
+
+ const joinConditions = joins.map(({ joinTo, localField, foreignField }) => {
+ return `LEFT JOIN "${joinTo}" ON "${joinTo}"."${foreignField}" = "${localField}"`;
+ });
+
+ const joinSelectClause = joins.map(({ as, joinTo, columns }) => {
+ const jsonColumns = columns
+ ?.map((col) => `'${col}', ${joinTo}.${col}`)
+ .join(", ");
+
+ return `,json_build_object(${jsonColumns}) AS ${as}`;
+ });
+
+ return { joinConditionClause: joinConditions.join(" "), joinSelectClause };
+};
+
+/**
+ * Builds a safe SQL query with values embedded for debugging or logging purposes.
+ * WARNING: This is meant for debugging only. Never use this for actual query execution.
+ *
+ * @param sql The parameterized SQL query with $n placeholders
+ * @param values Array of values to replace the placeholders
+ * @returns SQL query with values safely embedded
+ */
+export function buildSafeQuery(sql: string, values: any[]): string {
+ if (!sql) return "";
+ if (!values || !values.length) return sql;
+
+ // Create a copy to avoid modifying the original SQL
+ let safeSql = sql;
+
+ // Replace $n placeholders with formatted values
+ safeSql = safeSql.replace(/\$(\d+)/g, (match, indexStr) => {
+ const index = parseInt(indexStr, 10) - 1;
+
+ // Check if the index is valid
+ if (index < 0 || index >= values.length) {
+ // Return the original placeholder if the index is out of bounds
+ return match;
+ }
+
+ const value = values[index];
+ return formatSqlValue(value);
+ });
+
+ return safeSql;
+}
+
+/**
+ * Formats a JavaScript value for safe inclusion in SQL.
+ * Handles various types including null, strings, numbers, booleans, dates, arrays, and objects.
+ *
+ * @param value The value to format for SQL
+ * @returns SQL-safe representation of the value
+ */
+export function formatSqlValue(value: any): string {
+ // Handle null and undefined
+ if (value === null || value === undefined) {
+ return "NULL";
+ }
+
+ // Handle numbers
+ if (typeof value === "number") {
+ // Handle NaN and Infinity
+ if (isNaN(value)) return "NULL";
+ if (!isFinite(value)) return "NULL";
+ return value.toString();
+ }
+
+ // Handle booleans
+ if (typeof value === "boolean") {
+ return value ? "TRUE" : "FALSE";
+ }
+
+ // Handle dates
+ if (value instanceof Date) {
+ // Format to ISO string and ensure proper SQL timestamp format
+ return `'${value
+ .toISOString()
+ .replace("T", " ")
+ .replace("Z", "")}'::timestamp`;
+ }
+
+ // Handle strings
+ if (typeof value === "string") {
+ // Escape single quotes by doubling them (SQL standard)
+ const escaped = value.replace(/'/g, "''");
+ return `'${escaped}'`;
+ }
+
+ // Handle arrays
+ if (Array.isArray(value)) {
+ if (value.length === 0) {
+ return `'{}'`;
+ }
+
+ // Format each array element and join with commas
+ const formattedElements = value.map((element) => formatSqlValue(element));
+ return `ARRAY[${formattedElements.join(",")}]`;
+ }
+
+ // Handle objects (convert to JSONB)
+ if (typeof value === "object") {
+ try {
+ // Safely stringify the object and escape any single quotes
+ const jsonStr = JSON.stringify(value).replace(/'/g, "''");
+ return `'${jsonStr}'::jsonb`;
+ } catch (error) {
+ // Fallback if JSON stringification fails
+ console.error("Error converting object to JSON:", error);
+ return "NULL";
+ }
+ }
+
+ // Default case - try to convert to string
+ try {
+ return `'${String(value).replace(/'/g, "''")}'`;
+ } catch (error) {
+ return "NULL";
+ }
+}
+
+/**
+ * Builds a SET clause for UPDATE SQL statements with proper type handling
+ * @param data Object containing field:value pairs to update
+ * @param startValues Initial values array for parameterized query
+ * @returns Object containing the SET clause and updated values array
+ */
+export const buildSetClause = (
+ data: Partial,
+ startValues: any[] = []
+): { setClause: string; values: any[] } => {
+ if (!data || Object.keys(data).length === 0) {
+ return { setClause: "", values: startValues };
+ }
+
+ const values = [...startValues];
+ const setClauses: string[] = [];
+
+ Object.entries(data).forEach(([key, value]) => {
+ // Convert key to snake_case for database compatibility
+ const snakeCaseKey = toSnakeCase(key);
+
+ // Use parameterized queries for values
+ const paramIndex = values.length + 1;
+ setClauses.push(`"${snakeCaseKey}" = $${paramIndex}`);
+ values.push(value);
+ });
+
+ return {
+ setClause: setClauses.join(", "),
+ values,
+ };
+};
+
+// Helper function to convert camelCase to snake_case
+export function toSnakeCase(str: string): string {
+ // return str
+ // ?.replace(/([A-Z])/g, "_$1")
+ // ?.replace(/^_/, "")
+ // ?.toLowerCase();
+
+ return str;
+}
diff --git a/src/backend/persistence/persistence-where-operator.ts b/src/backend/persistence/persistence-where-operator.ts
new file mode 100644
index 0000000..cbc6690
--- /dev/null
+++ b/src/backend/persistence/persistence-where-operator.ts
@@ -0,0 +1,203 @@
+/**
+ * Drizzle-style operator functions with direct generic approach
+ */
+
+import { IPersistenceJoin } from "./persistence-contracts";
+
+// Type for all possible operators
+type Operator =
+ | "="
+ | "<"
+ | ">"
+ | "<="
+ | ">="
+ | "<>"
+ | "like"
+ | "ilike"
+ | "in"
+ | "not in";
+
+// Base type for conditions
+export interface SimpleWhere {
+ key: keyof T;
+ operator: Operator;
+ value: any;
+}
+
+// Type for composite conditions
+export interface CompositeWhere {
+ AND?: Array>;
+ OR?: Array>;
+}
+
+// Union type for any condition
+export type WhereCondition = SimpleWhere | CompositeWhere;
+
+// Equal
+export function eq(key: K, value: T[K]): SimpleWhere {
+ return {
+ key,
+ operator: "=",
+ value,
+ };
+}
+
+// Not equal
+export function neq(key: K, value: T[K]): SimpleWhere {
+ return {
+ key,
+ operator: "<>",
+ value,
+ };
+}
+
+// Greater than
+export function gt(key: K, value: T[K]): SimpleWhere {
+ return {
+ key,
+ operator: ">",
+ value,
+ };
+}
+
+// Greater than or equal
+export function gte(key: K, value: T[K]): SimpleWhere {
+ return {
+ key,
+ operator: ">=",
+ value,
+ };
+}
+
+// Less than
+export function lt(key: K, value: T[K]): SimpleWhere {
+ return {
+ key,
+ operator: "<",
+ value,
+ };
+}
+
+// Less than or equal
+export function lte(key: K, value: T[K]): SimpleWhere {
+ return {
+ key,
+ operator: "<=",
+ value,
+ };
+}
+
+// Like (case-sensitive)
+export function like(key: keyof T, pattern: string): SimpleWhere {
+ return {
+ key,
+ operator: "like",
+ value: pattern,
+ };
+}
+
+// ILike (case-insensitive)
+export function ilike(key: keyof T, pattern: string): SimpleWhere {
+ return {
+ key,
+ operator: "ilike",
+ value: pattern,
+ };
+}
+
+// In list of values
+export function inArray(
+ key: K,
+ values: T[K][]
+): SimpleWhere {
+ return {
+ key,
+ operator: "in",
+ value: values,
+ };
+}
+
+// Not in list of values
+export function notInArray(
+ key: K,
+ values: T[K][]
+): SimpleWhere {
+ return {
+ key,
+ operator: "not in",
+ value: values,
+ };
+}
+
+// Is null
+export function isNull(key: keyof T): SimpleWhere {
+ return {
+ key,
+ operator: "=",
+ value: null,
+ };
+}
+
+// Is not null
+export function isNotNull(key: keyof T): SimpleWhere {
+ return {
+ key,
+ operator: "<>",
+ value: null,
+ };
+}
+
+// Logical AND
+export function and(
+ ...conditions: Array>
+): CompositeWhere {
+ return {
+ AND: conditions,
+ };
+}
+
+// Logical OR
+export function or(
+ ...conditions: Array>
+): CompositeWhere {
+ return {
+ OR: conditions,
+ };
+}
+
+// Types for orderBy
+export interface IPersistentOrderBy {
+ key: keyof T;
+ direction: "asc" | "desc";
+}
+
+// Sorting helpers
+export function asc(key: keyof T): IPersistentOrderBy {
+ return {
+ key,
+ direction: "asc",
+ };
+}
+
+export function desc(key: keyof T): IPersistentOrderBy {
+ return {
+ key,
+ direction: "desc",
+ };
+}
+
+export function joinTable(payload: {
+ as: string;
+ joinTo: string;
+ localField: keyof T;
+ foreignField: keyof U;
+ columns: Array;
+}): IPersistenceJoin {
+ return {
+ as: payload.as,
+ joinTo: payload.joinTo,
+ localField: payload.localField.toString(),
+ foreignField: payload.foreignField.toString(),
+ columns: payload.columns.map((col) => col.toString()),
+ };
+}
diff --git a/src/backend/persistence/persistence.repository.ts b/src/backend/persistence/persistence.repository.ts
new file mode 100644
index 0000000..f80b582
--- /dev/null
+++ b/src/backend/persistence/persistence.repository.ts
@@ -0,0 +1,238 @@
+import { log } from "console";
+import {
+ AppPaginationResponseDto,
+ IPagination,
+ IPersistentDriver,
+ IPersistentPaginationPayload,
+ IPersistentQueryPayload,
+ IPersistentUpdatePayload,
+} from "./persistence-contracts";
+import {
+ buildJoinClause,
+ buildOrderByClause,
+ buildSetClause,
+ buildWhereClause,
+ toSnakeCase,
+} from "./persistence-utils";
+import { removeNullOrUndefinedFromObject } from "@/lib/utils";
+
+export class PersistentRepository {
+ constructor(
+ public readonly tableName: string,
+ public readonly persistentDriver: IPersistentDriver
+ ) {}
+
+ async findAllWithPagination(
+ payload: IPagination
+ ): Promise> {
+ const _limit = payload.limit || 10;
+ const _page = payload.page || 1;
+ const _offset = (_page - 1) * _limit;
+
+ // Execute the main query
+ const nodes = await this.findRows({
+ limit: _limit,
+ offset: _offset,
+ columns: payload?.columns || [],
+ where: payload?.where || undefined,
+ orderBy: payload?.orderBy || [],
+ joins: payload?.joins || [],
+ });
+
+ // Execute the count query
+ const totalCountResult =
+ (await this.findRowCount({
+ where: payload?.where || undefined,
+ })) || 0;
+
+ return {
+ nodes: nodes as DOMAIN_MODEL_TYPE[],
+ meta: {
+ totalCount: +totalCountResult,
+ currentPage: +_page,
+ hasNextPage: _page * _limit < totalCountResult,
+ totalPages: +Math.ceil(totalCountResult / _limit),
+ },
+ };
+ }
+
+ /**
+ * Finds rows in the database based on the provided where conditions.
+ * @param payload
+ */
+ async findRows(payload: IPersistentPaginationPayload) {
+ // Default columns to '*' if none are provided
+ const columns =
+ payload.columns
+ ?.map((col) => `${this.tableName}.${col.toString()}`)
+ .join(",") ?? "*";
+ const { whereClause, values } = buildWhereClause(payload.where);
+ const orderByClause = buildOrderByClause(payload?.orderBy);
+ const { joinConditionClause, joinSelectClause } = buildJoinClause(
+ payload.joins
+ );
+
+ // Build the SQL query with LIMIT, OFFSET, and ORDER BY
+ const limit = payload.limit == -1 ? undefined : payload.limit; // Default limit to 10 if not provided
+ const offset = payload.offset ?? 0; // Default offset to 0 if not provided
+
+ const sqlQuery = `
+ SELECT ${columns}
+ ${joinSelectClause ? `${joinSelectClause.join(",")}` : ""}
+ FROM ${this.tableName}
+ ${joinConditionClause ? joinConditionClause : ""}
+ ${whereClause ? `WHERE ${whereClause}` : ""}
+ ${orderByClause ? orderByClause : ""}
+ ${limit ? `LIMIT ${limit}` : ""} ${offset ? `OFFSET ${offset}` : ""};
+ `;
+
+ // Execute the SQL query
+ const result = await this.persistentDriver.executeSQL(sqlQuery, values);
+ return result.rows as DOMAIN_MODEL_TYPE[];
+ }
+
+ /**
+ * Finds the count of rows in the database based on the provided where conditions.
+ * @param payload
+ */
+ async findRowCount(
+ payload: IPersistentPaginationPayload
+ ): Promise {
+ const { whereClause, values } = buildWhereClause(payload.where);
+
+ // Construct the SQL query
+ const query = `
+ SELECT COUNT(*) as count
+ FROM ${this.tableName}
+ ${whereClause ? `WHERE ${whereClause}` : ""};
+ `;
+
+ const result = await this.persistentDriver.executeSQL(query, values);
+ return parseInt(result.rows[0].count, 10);
+ }
+
+ /**
+ * Creates a new record in the database.
+ *
+ * @param data - The data to be inserted.
+ * @returns The newly created record.
+ */
+ async createOne(
+ data: Partial
+ ): Promise {
+ // Prepare columns and placeholders for the insert statement
+ function toSnakeCase(str: string): string {
+ return str
+ .replace(/([A-Z])/g, "_$1")
+ .replace(/^_/, "")
+ .toLowerCase();
+ }
+
+ const columns = Object.keys(data).map(toSnakeCase).join(", ");
+
+ const placeholders = Object.keys(data)
+ .map((_, index) => `$${index + 1}`)
+ .join(", ");
+ const values = Object.values(data) as any[];
+
+ // Build the SQL query
+ const sql = `
+ INSERT INTO ${this.tableName} (${columns},created_at,updated_at)
+ VALUES (${placeholders}, now(), now())
+ RETURNING *;
+ `;
+
+ // Execute the SQL query
+ const result = await this.executeSQL(sql, values);
+ return result.rows[0] as DOMAIN_MODEL_TYPE;
+ }
+
+ /**
+ * Creates multiple records in the database.
+ *
+ * @param payload
+ */
+ async createMany(
+ payload: Partial[]
+ ): Promise {
+ const results = [];
+
+ for (const data of payload) {
+ results.push(await this.createOne(data));
+ }
+ return results as DOMAIN_MODEL_TYPE[];
+ }
+
+ /**
+ * Updates a record in the database based on where conditions.
+ * @param payload Query conditions
+ */
+ async updateOne(
+ payload: IPersistentUpdatePayload
+ ): Promise {
+ // Build WHERE clause
+ const { whereClause, values: whereValues } = buildWhereClause(
+ payload.where
+ );
+
+ // Build SET clause using the where values as starting point
+ const { setClause, values: allValues } = buildSetClause(
+ removeNullOrUndefinedFromObject(payload.data),
+ whereValues
+ );
+
+ // Handle columns for RETURNING
+ const columns = toSnakeCase((payload.columns as any) || ["*"]);
+
+ // Build final SQL query
+ const sql = `
+ UPDATE ${this.tableName}
+ ${setClause ? `SET ${setClause}` : ""},"updated_at" = now()
+ ${whereClause ? `WHERE ${whereClause}` : ""}
+ RETURNING ${columns};
+ `;
+
+ // Execute the SQL query
+ const result = await this.persistentDriver.executeSQL(sql, allValues);
+
+ if (!result.rows || result.rows.length === 0) {
+ throw new Error(`No record found to update with the given conditions`);
+ }
+
+ return result.rows[0] as DOMAIN_MODEL_TYPE;
+ }
+
+ /**
+ * Deletes rows in the database based on the provided where conditions.
+ * @param payload
+ */
+ async deleteRows(
+ payload: IPersistentQueryPayload
+ ): Promise {
+ const { whereClause, values } = buildWhereClause(payload.where);
+ const columns = toSnakeCase(payload.columns as any) || "*";
+
+ const sql = `
+ DELETE
+ FROM ${this.tableName} ${
+ whereClause ? `WHERE ${whereClause}` : ""
+ } RETURNING ${columns};
+ `;
+
+ const result = await this.executeSQL(sql, values);
+ return result.rows as DOMAIN_MODEL_TYPE[];
+ }
+
+ /**
+ * Executes a raw SQL query with the provided values.
+ * @param sql
+ * @param values
+ */
+ executeSQL(sql: string, values: string[]) {
+ try {
+ return this.persistentDriver.executeSQL(sql, values);
+ } catch (e) {
+ throw new Error("Error in executeSQL");
+ }
+ }
+}
diff --git a/src/backend/schemas/schemas.ts b/src/backend/schemas/schemas.ts
new file mode 100644
index 0000000..10c93c5
--- /dev/null
+++ b/src/backend/schemas/schemas.ts
@@ -0,0 +1,123 @@
+import {
+ AnyPgColumn,
+ boolean,
+ json,
+ jsonb,
+ pgTable,
+ serial,
+ text,
+ timestamp,
+ uuid,
+ varchar,
+} from "drizzle-orm/pg-core";
+import { IServerFile } from "../models/domain-models";
+
+export const usersTable = pgTable("users", {
+ id: uuid("id").defaultRandom().primaryKey(),
+ name: varchar("name").notNull(),
+ username: varchar("username").notNull(),
+ email: varchar("email"),
+ profile_photo: varchar("profile_photo"),
+ education: varchar("education"),
+ designation: varchar("designation"),
+ bio: varchar("bio"),
+ websiteUrl: varchar("website_url"),
+ location: varchar("location"),
+ social_links: json("social_links"),
+ profile_readme: text("profile_readme"),
+ skills: varchar("skills"),
+ created_at: timestamp("created_at"),
+ updated_at: timestamp("updated_at"),
+});
+
+export const userSocialsTable = pgTable("user_socials", {
+ id: serial("id").primaryKey(),
+ service: varchar("service").notNull(),
+ service_uid: varchar("service_uid").notNull(),
+ user_id: uuid("user_id")
+ .notNull()
+ .references(() => usersTable.id, { onDelete: "cascade" }),
+ created_at: timestamp("created_at"),
+ updated_at: timestamp("updated_at"),
+});
+
+export const userSessionsTable = pgTable("user_sessions", {
+ id: uuid("id").defaultRandom().primaryKey(),
+ user_id: uuid("user_id")
+ .notNull()
+ .references(() => usersTable.id, { onDelete: "cascade" }),
+ token: varchar("token").notNull(),
+ device: varchar("device"), // os + browser
+ ip: varchar("ip"),
+ last_action_at: timestamp("last_action_at"),
+ created_at: timestamp("created_at"),
+ updated_at: timestamp("updated_at"),
+});
+
+export const userFollowsTable = pgTable("user_follows", {
+ id: serial("id").primaryKey(),
+ follower_id: uuid("follower_id")
+ .notNull()
+ .references(() => usersTable.id, { onDelete: "cascade" }),
+ followee_id: uuid("followee_id")
+ .notNull()
+ .references(() => usersTable.id, { onDelete: "cascade" }),
+ created_at: timestamp("created_at"),
+ updated_at: timestamp("updated_at"),
+});
+
+export const articlesTable = pgTable("articles", {
+ id: uuid("id").defaultRandom().primaryKey(),
+ title: varchar("title").notNull(),
+ handle: varchar("handle").notNull(),
+ excerpt: varchar("excerpt"),
+ body: text("body"),
+ cover_image: jsonb("cover_image").$type(),
+ is_published: boolean("is_published").default(false),
+ published_at: timestamp("published_at"),
+ approved_at: timestamp("approved_at"),
+ metadata: jsonb("metadata"),
+ author_id: uuid("author_id")
+ .notNull()
+ .references(() => usersTable.id, { onDelete: "cascade" }),
+ created_at: timestamp("created_at"),
+ updated_at: timestamp("updated_at"),
+});
+
+export const commentsTable = pgTable("comments", {
+ id: uuid("id").defaultRandom().primaryKey(),
+ body: text("body").notNull(),
+ commentable_type: varchar("commentable_type").notNull(),
+ commentable_id: uuid("commentable_id").notNull(),
+ user_id: uuid("user_id")
+ .notNull()
+ .references(() => usersTable.id, { onDelete: "cascade" }),
+ parent_id: uuid("parent_id").references((): AnyPgColumn => commentsTable.id, {
+ onDelete: "cascade",
+ }),
+ created_at: timestamp("created_at").defaultNow(),
+ updated_at: timestamp("updated_at").defaultNow(),
+});
+
+export const tags = pgTable("tags", {
+ id: uuid("id").defaultRandom().primaryKey(),
+ name: varchar("name", { length: 50 }).notNull(),
+ icon: jsonb("icon").$type(),
+ color: varchar("color", { length: 6 }),
+ description: text("description"),
+ created_at: timestamp("created_at"),
+ updated_at: timestamp("updated_at"),
+});
+
+export const articleTagsTable = pgTable("article_tag", {
+ id: serial("id").primaryKey(),
+ article_id: uuid("article_id")
+ .notNull()
+ .references(() => articlesTable.id, { onDelete: "cascade" }),
+ tag_id: uuid("tag_id")
+ .notNull()
+ .references(() => tags.id, { onDelete: "cascade" }),
+
+ created_at: timestamp("created_at"),
+ updated_at: timestamp("updated_at"),
+});
diff --git a/src/backend/services/RepositoryException.ts b/src/backend/services/RepositoryException.ts
new file mode 100644
index 0000000..d08274e
--- /dev/null
+++ b/src/backend/services/RepositoryException.ts
@@ -0,0 +1,27 @@
+import { zodErrorToString } from "@/lib/utils";
+import { z } from "zod";
+
+export class RepositoryException extends Error {
+ constructor(message?: string, options?: ErrorOptions) {
+ super(message, options);
+ this.message = message ?? "Validation error";
+ }
+
+ toString(): string {
+ return this.message;
+ }
+}
+
+export const handleRepositoryException = (error: any) => {
+ if (error instanceof RepositoryException) {
+ return error.toString();
+ }
+
+ if (error instanceof Error) {
+ return error.message;
+ }
+
+ if (error instanceof z.ZodError) {
+ throw new RepositoryException(zodErrorToString(error));
+ }
+};
diff --git a/src/backend/services/action-type.ts b/src/backend/services/action-type.ts
new file mode 100644
index 0000000..057a542
--- /dev/null
+++ b/src/backend/services/action-type.ts
@@ -0,0 +1,23 @@
+export enum USER_SESSION_KEY {
+ SESSION_TOKEN = "session_token",
+ SESSION_USER_ID = "session_userId",
+}
+
+export interface ISession {
+ id: string;
+ user_id: string;
+ token: string;
+ device?: string;
+}
+
+export interface ISessionUser {
+ id: string;
+ name: string;
+ username: string;
+ email: string;
+ profile_photo: string;
+}
+
+export type SessionResult =
+ | { session: ISession; user: ISessionUser }
+ | { session: null; user: null };
diff --git a/src/backend/services/article.actions.ts b/src/backend/services/article.actions.ts
new file mode 100644
index 0000000..d7f7c54
--- /dev/null
+++ b/src/backend/services/article.actions.ts
@@ -0,0 +1,449 @@
+"use server";
+
+import { generateRandomString, removeMarkdownSyntax } from "@/lib/utils";
+import { z } from "zod";
+import { Article, User } from "../models/domain-models";
+import { pgClient } from "../persistence/database-drivers/pg.client";
+import {
+ and,
+ desc,
+ eq,
+ joinTable,
+ neq,
+} from "../persistence/persistence-where-operator";
+import { PersistentRepository } from "../persistence/persistence.repository";
+import {
+ handleRepositoryException,
+ RepositoryException,
+} from "./RepositoryException";
+import { ArticleRepositoryInput } from "./inputs/article.input";
+import { getSessionUserId } from "./session.actions";
+import { slugify } from "@/lib/slug-helper.util";
+
+const articleRepository = new PersistentRepository(
+ "articles",
+ pgClient
+);
+
+/**
+ * Creates a new article in the database.
+ *
+ * @param _input - The article data to create, validated against ArticleRepositoryInput.createArticleInput schema
+ * @returns Promise - The newly created article
+ * @throws {RepositoryException} If article creation fails or validation fails
+ */
+export async function createArticle(
+ _input: z.infer
+) {
+ try {
+ const input =
+ await ArticleRepositoryInput.createArticleInput.parseAsync(_input);
+ const article = await articleRepository.createOne({
+ title: input.title,
+ handle: input.handle,
+ excerpt: input.excerpt ?? null,
+ body: input.body ?? null,
+ cover_image: input.cover_image ?? null,
+ is_published: input.is_published ?? false,
+ published_at: input.is_published ? new Date() : null,
+ author_id: input.author_id,
+ });
+ return article;
+ } catch (error) {
+ handleRepositoryException(error);
+ }
+}
+
+export async function createMyArticle(
+ _input: z.infer
+) {
+ try {
+ const sessionUserId = await getSessionUserId();
+ if (!sessionUserId) {
+ throw new RepositoryException("Unauthorized");
+ }
+
+ const input =
+ await ArticleRepositoryInput.createMyArticleInput.parseAsync(_input);
+
+ const handle = await getUniqueArticleHandle(input.title);
+
+ const article = await articleRepository.createOne({
+ title: input.title,
+ handle: handle,
+ excerpt: input.excerpt ?? null,
+ body: input.body ?? null,
+ cover_image: input.cover_image ?? null,
+ is_published: input.is_published ?? false,
+ published_at: input.is_published ? new Date() : null,
+ author_id: sessionUserId,
+ });
+ return article;
+ } catch (error) {
+ handleRepositoryException(error);
+ }
+}
+
+export const getUniqueArticleHandle = async (title: string) => {
+ try {
+ const count = await articleRepository.findRowCount({
+ where: eq("handle", slugify(title)),
+ columns: ["id", "handle"],
+ });
+ if (count) {
+ return `${slugify(title)}-${count + 1}`;
+ }
+ return slugify(title);
+ } catch (error) {
+ handleRepositoryException(error);
+ }
+};
+
+/**
+ * Updates an existing article in the database.
+ *
+ * @param _input - The article update data, validated against ArticleRepositoryInput.updateArticleInput schema
+ * @returns Promise - The updated article
+ * @throws {RepositoryException} If article update fails, article not found, or validation fails
+ */
+export async function updateArticle(
+ _input: z.infer
+) {
+ try {
+ const input =
+ await ArticleRepositoryInput.updateArticleInput.parseAsync(_input);
+ const article = await articleRepository.updateOne({
+ where: eq("id", input.article_id),
+ data: {
+ title: input.title,
+ handle: input.handle,
+ excerpt: input.excerpt,
+ body: input.body,
+ cover_image: input.cover_image,
+ is_published: input.is_published,
+ published_at: input.is_published ? new Date() : null,
+ },
+ });
+ return article;
+ } catch (error) {
+ handleRepositoryException(error);
+ }
+}
+
+export async function updateMyArticle(
+ _input: z.infer
+) {
+ try {
+ const sessionUserId = await getSessionUserId();
+ if (!sessionUserId) {
+ throw new RepositoryException("Unauthorized");
+ }
+
+ const input =
+ await ArticleRepositoryInput.updateMyArticleInput.parseAsync(_input);
+ const article = await articleRepository.updateOne({
+ where: and(eq("id", input.article_id), eq("author_id", sessionUserId)),
+ data: {
+ title: input.title,
+ handle: input.handle,
+ excerpt: input.excerpt,
+ body: input.body,
+ cover_image: input.cover_image,
+ is_published: input.is_published,
+ published_at: input.is_published ? new Date() : null,
+ },
+ });
+ return article;
+ } catch (error) {
+ handleRepositoryException(error);
+ }
+}
+
+/**
+ * Deletes an article from the database.
+ *
+ * @param article_id - The unique identifier of the article to delete
+ * @returns Promise - The deleted article
+ * @throws {RepositoryException} If article deletion fails or article not found
+ */
+export async function deleteArticle(article_id: string) {
+ try {
+ const deletedArticles = await articleRepository.deleteRows({
+ where: eq("id", article_id),
+ });
+
+ if (!deletedArticles || deletedArticles.length === 0) {
+ throw new RepositoryException(
+ "Article not found or could not be deleted"
+ );
+ }
+
+ return deletedArticles[0];
+ } catch (error) {
+ handleRepositoryException(error);
+ }
+}
+
+/**
+ * Retrieves the most recent published articles.
+ *
+ * @param limit - Maximum number of articles to return (default: 5)
+ * @returns Promise - Array of recent articles with author information
+ * @throws {RepositoryException} If query fails
+ */
+export async function findRecentArticles(
+ limit: number = 5
+): Promise {
+ try {
+ return articleRepository.findRows({
+ where: and(eq("is_published", true), neq("published_at", null)),
+ limit,
+ orderBy: [desc("published_at")],
+ columns: ["id", "title", "handle"],
+ joins: [
+ joinTable({
+ as: "user",
+ joinTo: "users",
+ localField: "author_id",
+ foreignField: "id",
+ columns: ["id", "name", "username", "profile_photo"],
+ }),
+ ],
+ });
+ } catch (error) {
+ handleRepositoryException(error);
+ return [];
+ }
+}
+
+/**
+ * Retrieves a paginated feed of published articles.
+ *
+ * @param _input - Feed parameters including page and limit, validated against ArticleRepositoryInput.feedInput schema
+ * @returns Promise<{ data: Article[], total: number }> - Paginated articles with total count
+ * @throws {RepositoryException} If query fails or validation fails
+ */
+export async function articleFeed(
+ _input: z.infer
+) {
+ try {
+ const input = await ArticleRepositoryInput.feedInput.parseAsync(_input);
+
+ const response = await articleRepository.findAllWithPagination({
+ where: and(eq("is_published", true), neq("approved_at", null)),
+ page: input.page,
+ limit: input.limit,
+ orderBy: [desc("published_at")],
+ columns: [
+ "id",
+ "title",
+ "handle",
+ "cover_image",
+ "body",
+ "created_at",
+ "excerpt",
+ ],
+ joins: [
+ joinTable({
+ as: "user",
+ joinTo: "users",
+ localField: "author_id",
+ foreignField: "id",
+ columns: ["id", "name", "username", "profile_photo"],
+ }),
+ ],
+ });
+
+ response["nodes"] = response["nodes"].map((article) => {
+ return {
+ ...article,
+ excerpt: article.excerpt ?? removeMarkdownSyntax(article.body),
+ };
+ });
+
+ return response;
+ } catch (error) {
+ handleRepositoryException(error);
+ }
+}
+
+export async function userArticleFeed(
+ _input: z.infer,
+ columns?: (keyof Article)[]
+) {
+ try {
+ const input = await ArticleRepositoryInput.userFeedInput.parseAsync(_input);
+
+ const response = await articleRepository.findAllWithPagination({
+ where: and(
+ eq("is_published", true),
+ neq("approved_at", null),
+ eq("author_id", input.user_id)
+ ),
+ page: input.page,
+ limit: input.limit,
+ orderBy: [desc("published_at")],
+ columns: columns ?? [
+ "id",
+ "title",
+ "handle",
+ "cover_image",
+ "body",
+ "created_at",
+ "excerpt",
+ ],
+ joins: [
+ joinTable({
+ as: "user",
+ joinTo: "users",
+ localField: "author_id",
+ foreignField: "id",
+ columns: ["id", "name", "username", "profile_photo"],
+ }),
+ ],
+ });
+
+ response["nodes"] = response["nodes"].map((article) => {
+ return {
+ ...article,
+ excerpt: article.excerpt ?? removeMarkdownSyntax(article.body),
+ };
+ });
+
+ return response;
+ } catch (error) {
+ handleRepositoryException(error);
+ }
+}
+
+export async function articleDetailByHandle(article_handle: string) {
+ try {
+ const [article] = await articleRepository.findRows({
+ where: eq("handle", article_handle),
+ columns: [
+ "id",
+ "title",
+ "handle",
+ "excerpt",
+ "body",
+ "cover_image",
+ "is_published",
+ "published_at",
+ "approved_at",
+ "metadata",
+ "author_id",
+ "created_at",
+ "updated_at",
+ ],
+ joins: [
+ joinTable({
+ as: "user",
+ joinTo: "users",
+ localField: "author_id",
+ foreignField: "id",
+ columns: ["id", "name", "username", "profile_photo"],
+ }),
+ ],
+ limit: 1,
+ });
+
+ if (!article) {
+ throw new RepositoryException("Article not found");
+ }
+
+ return article;
+ } catch (error) {
+ handleRepositoryException(error);
+ }
+}
+
+export async function articleDetailByUUID(uuid: string) {
+ try {
+ const [article] = await articleRepository.findRows({
+ where: eq("id", uuid),
+ columns: [
+ "id",
+ "title",
+ "handle",
+ "excerpt",
+ "body",
+ "cover_image",
+ "is_published",
+ "published_at",
+ "approved_at",
+ "metadata",
+ "author_id",
+ "created_at",
+ "updated_at",
+ ],
+ joins: [
+ joinTable({
+ as: "user",
+ joinTo: "users",
+ localField: "author_id",
+ foreignField: "id",
+ columns: ["id", "name", "username", "profile_photo"],
+ }),
+ ],
+ limit: 1,
+ });
+
+ if (!article) {
+ throw new RepositoryException("Article not found");
+ }
+
+ return article;
+ } catch (error) {
+ handleRepositoryException(error);
+ }
+}
+
+export async function myArticles(
+ input: z.infer
+) {
+ const sessionUserId = await getSessionUserId();
+ try {
+ const articles = await articleRepository.findAllWithPagination({
+ where: eq("author_id", sessionUserId),
+ columns: [
+ "id",
+ "title",
+ "handle",
+ "created_at",
+ "is_published",
+ "approved_at",
+ ],
+ limit: input.limit,
+ page: input.page,
+ orderBy: [desc("created_at")],
+ });
+ return articles;
+ } catch (error) {
+ handleRepositoryException(error);
+ }
+}
+
+/**
+ * Updates the status of an article.
+ * @param article_id - The unique identifier of the article to update
+ * @param is_published - The new status of the article
+ * @returns
+ */
+export async function setArticlePublished(
+ article_id: string,
+ is_published: boolean
+) {
+ const sessionUserId = await getSessionUserId();
+ try {
+ const articles = await articleRepository.updateOne({
+ where: and(eq("id", article_id), eq("author_id", sessionUserId)),
+ data: {
+ is_published: is_published,
+ published_at: is_published ? new Date() : null,
+ },
+ });
+ return articles;
+ } catch (error) {
+ handleRepositoryException(error);
+ }
+}
diff --git a/src/backend/services/dashboard.action.ts b/src/backend/services/dashboard.action.ts
new file mode 100644
index 0000000..08fc88a
--- /dev/null
+++ b/src/backend/services/dashboard.action.ts
@@ -0,0 +1,33 @@
+"use server";
+
+import * as sessionActions from "@/backend/services/session.actions";
+import { persistenceRepository } from "../persistence-repositories";
+import { sql } from "../persistence/persistence-utils";
+
+const query = sql`
+SELECT (SELECT Count(*)
+ FROM articles
+ WHERE author_id = $1)
+ AS total_articles,
+ (SELECT Count(*)
+ FROM comments
+ WHERE comments.commentable_type = 'ARTICLE'
+ AND comments.commentable_id IN (SELECT id
+ FROM articles
+ WHERE articles.author_id = $1))
+ AS total_comments
+`;
+
+export async function myArticleMatrix() {
+ const sessionUserId = await sessionActions.getSessionUserId();
+
+ const totalPostsQuery = await persistenceRepository.article.executeSQL(
+ query,
+ [sessionUserId!]
+ );
+
+ return {
+ total_articles: totalPostsQuery.rows[0].total_articles,
+ total_comments: totalPostsQuery.rows[0].total_comments,
+ };
+}
diff --git a/src/backend/services/inputs/article.input.ts b/src/backend/services/inputs/article.input.ts
new file mode 100644
index 0000000..5f0dd4d
--- /dev/null
+++ b/src/backend/services/inputs/article.input.ts
@@ -0,0 +1,107 @@
+import { z } from "zod";
+
+export const ArticleRepositoryInput = {
+ createArticleInput: z.object({
+ title: z.string(),
+ handle: z.string(),
+ excerpt: z.string().optional().nullable(),
+ body: z.string().optional().nullable(),
+ cover_image: z
+ .object({
+ key: z.string(),
+ provider: z.enum(["cloudinary", "direct"]),
+ })
+ .optional()
+ .nullable(),
+ is_published: z.boolean().optional().nullable(),
+ author_id: z.string(),
+ }),
+
+ createMyArticleInput: z.object({
+ title: z.string(),
+ excerpt: z.string().optional().nullable(),
+ body: z.string().optional().nullable(),
+ cover_image: z
+ .object({
+ key: z.string(),
+ provider: z.enum(["cloudinary", "direct"]),
+ })
+ .optional()
+ .nullable(),
+ is_published: z.boolean().optional().nullable(),
+ }),
+
+ updateArticleInput: z.object({
+ article_id: z.string(),
+ title: z.string().optional(),
+ handle: z.string().optional(),
+ excerpt: z.string().optional(),
+ body: z.string().optional(),
+ cover_image: z
+ .object({
+ key: z.string(),
+ provider: z.enum(["cloudinary", "direct"]),
+ })
+ .optional(),
+ is_published: z.boolean().optional(),
+ }),
+ updateMyArticleInput: z.object({
+ article_id: z.string(),
+ title: z.string().optional(),
+ handle: z.string().optional(),
+ excerpt: z.string().optional(),
+ body: z.string().optional(),
+ cover_image: z
+ .object({
+ key: z.string(),
+ provider: z.enum(["cloudinary", "direct"]),
+ alt: z.string().optional(),
+ })
+ .optional(),
+ is_published: z.boolean().optional(),
+ metadata: z
+ .object({
+ seo: z
+ .object({
+ title: z.string().optional(),
+ description: z.string().optional(),
+ keywords: z.array(z.string()).optional(),
+ canonical_url: z.string().url().optional(),
+ })
+ .nullable()
+ .optional(),
+ })
+ .nullable()
+ .optional(),
+ }),
+
+ feedInput: z.object({
+ page: z.number().default(1),
+ limit: z.number().default(10),
+ }),
+
+ userFeedInput: z.object({
+ user_id: z.string(),
+ page: z.number().default(1),
+ limit: z.number().default(10),
+ }),
+
+ findArticlesByAuthorInput: z.object({
+ author_id: z.string(),
+ published_only: z.boolean().default(false),
+ page: z.number().default(1),
+ limit: z.number().default(10),
+ }),
+
+ searchArticlesInput: z.object({
+ search_term: z.string().optional(),
+ published_only: z.boolean().default(true),
+ page: z.number().default(1),
+ limit: z.number().default(10),
+ }),
+
+ myArticleInput: z.object({
+ page: z.number().default(1),
+ limit: z.number().default(10),
+ }),
+};
diff --git a/src/backend/services/inputs/session.input.ts b/src/backend/services/inputs/session.input.ts
new file mode 100644
index 0000000..cf12919
--- /dev/null
+++ b/src/backend/services/inputs/session.input.ts
@@ -0,0 +1,8 @@
+import { z } from "zod";
+
+export const UserSessionInput = {
+ createLoginSessionInput: z.object({
+ user_id: z.string(),
+ request: z.instanceof(Request),
+ }),
+};
diff --git a/src/backend/services/inputs/user.input.ts b/src/backend/services/inputs/user.input.ts
new file mode 100644
index 0000000..8bef8f0
--- /dev/null
+++ b/src/backend/services/inputs/user.input.ts
@@ -0,0 +1,28 @@
+import { z } from "zod";
+
+export const UserRepositoryInput = {
+ syncSocialUserInput: z.object({
+ service: z.enum(["github"]),
+ service_uid: z.string(),
+ name: z.string(),
+ username: z.string(),
+ email: z.string().email(),
+ profile_photo: z.string().url(),
+ bio: z.string().optional().nullable(),
+ }),
+ updateUserProfileInput: z.object({
+ id: z.string(),
+ name: z.string(),
+ username: z.string(),
+ email: z.string().email(),
+ profile_photo: z.string().url(),
+ education: z.string().optional(),
+ designation: z.string().optional(),
+ bio: z.string().optional(),
+ websiteUrl: z.string().url().optional(),
+ location: z.string().optional(),
+ social_links: z.record(z.string()).optional(),
+ profile_readme: z.string().optional(),
+ skills: z.string().optional(),
+ }),
+};
diff --git a/src/backend/services/oauth/GithubOAuthService.ts b/src/backend/services/oauth/GithubOAuthService.ts
new file mode 100644
index 0000000..21bc36c
--- /dev/null
+++ b/src/backend/services/oauth/GithubOAuthService.ts
@@ -0,0 +1,98 @@
+import { generateRandomString } from "@/lib/utils";
+import { IGithubUser, IOAuthService } from "./oauth-contract";
+import { cookies } from "next/headers";
+import { env } from "@/env";
+
+export class GithubOAuthService implements IOAuthService {
+ async getAuthorizationUrl(): Promise {
+ const state = generateRandomString(50);
+ const params = new URLSearchParams({
+ client_id: env.GITHUB_CLIENT_ID,
+ redirect_uri: env.GITHUB_CALLBACK_URL,
+ scope: "user:email",
+ state,
+ });
+ const _cookies = await cookies();
+ _cookies.set("github_oauth_state", state, {
+ path: "/",
+ secure: env.NODE_ENV === "production",
+ httpOnly: true,
+ maxAge: 60 * 10,
+ sameSite: "lax",
+ });
+ return `https://github.com/login/oauth/authorize?${params.toString()}`;
+ }
+
+ async getUserInfo(code: string, state: string): Promise {
+ const _cookies = await cookies();
+ const storedState = _cookies.get("github_oauth_state")?.value ?? null;
+
+ if (code === null || state === null || storedState === null) {
+ throw new Error("Please restart the process.");
+ }
+ if (state !== storedState) {
+ throw new Error("Please restart the process.");
+ }
+
+ const githubAccessToken = await validateGitHubCode(
+ code,
+ env.GITHUB_CLIENT_ID,
+ env.GITHUB_CLIENT_SECRET,
+ env.GITHUB_CALLBACK_URL
+ );
+
+ const githubUser = await getGithubUser(githubAccessToken.access_token);
+ return githubUser;
+ }
+}
+
+interface GitHubTokenResponse {
+ access_token: string;
+ token_type: string;
+ scope: string;
+}
+
+export const validateGitHubCode = async (
+ code: string,
+ clientId: string,
+ clientSecret: string,
+ redirectUri: string
+): Promise => {
+ const params = new URLSearchParams({
+ client_id: clientId,
+ client_secret: clientSecret,
+ code: code,
+ redirect_uri: redirectUri,
+ });
+
+ const response = await fetch(
+ `https://github.com/login/oauth/access_token?${params.toString()}`,
+ {
+ method: "POST",
+ headers: {
+ Accept: "application/json",
+ },
+ }
+ );
+
+ if (!response.ok) {
+ throw new Error("Failed to validate GitHub code");
+ }
+
+ const data = await response.json();
+ return data;
+};
+
+const getGithubUser = async (accessToken: string): Promise => {
+ const githubAPI = await fetch("https://api.github.com/user", {
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ },
+ });
+ if (!githubAPI.ok) {
+ throw new Error("Failed to get GitHub user");
+ }
+
+ const githubUserResponse = (await githubAPI.json()) as IGithubUser;
+ return githubUserResponse;
+};
diff --git a/src/backend/services/oauth/oauth-contract.ts b/src/backend/services/oauth/oauth-contract.ts
new file mode 100644
index 0000000..5e9cba8
--- /dev/null
+++ b/src/backend/services/oauth/oauth-contract.ts
@@ -0,0 +1,41 @@
+export interface IOAuthService {
+ getAuthorizationUrl(state: string, clientId: string): Promise;
+ getUserInfo(code: string, state: string): Promise;
+}
+
+export interface IGithubUser {
+ login: string;
+ id: number;
+ node_id: string;
+ avatar_url: string;
+ gravatar_id: string;
+ url: string;
+ html_url: string;
+ followers_url: string;
+ following_url: string;
+ gists_url: string;
+ starred_url: string;
+ subscriptions_url: string;
+ organizations_url: string;
+ repos_url: string;
+ events_url: string;
+ received_events_url: string;
+ type: string;
+ user_view_type: string;
+ site_admin: boolean;
+ name: string;
+ company: string;
+ blog: string;
+ location: string;
+ email: string;
+ hireable: boolean;
+ bio: string;
+ twitter_username: string;
+ notification_email: string;
+ public_repos: number;
+ public_gists: number;
+ followers: number;
+ following: number;
+ created_at: Date;
+ updated_at: Date;
+}
diff --git a/src/backend/services/session.actions.ts b/src/backend/services/session.actions.ts
new file mode 100644
index 0000000..88e98bb
--- /dev/null
+++ b/src/backend/services/session.actions.ts
@@ -0,0 +1,196 @@
+"use server";
+
+import { generateRandomString } from "@/lib/utils";
+import { cookies } from "next/headers";
+import { userAgent } from "next/server";
+import { cache } from "react";
+import { z } from "zod";
+import { UserSession } from "../models/domain-models";
+import { persistenceRepository } from "../persistence-repositories";
+import { pgClient } from "../persistence/database-drivers/pg.client";
+import { eq } from "../persistence/persistence-where-operator";
+import { PersistentRepository } from "../persistence/persistence.repository";
+import { handleRepositoryException } from "./RepositoryException";
+import { SessionResult, USER_SESSION_KEY } from "./action-type";
+import { UserSessionInput } from "./inputs/session.input";
+import { env } from "@/env";
+
+const sessionRepository = new PersistentRepository(
+ "user_sessions",
+ pgClient
+);
+
+/**
+ * Creates a new login session for a user and sets a session cookie.
+ *
+ * @param _input - The session data containing user_id and request object, validated against UserSessionInput.createLoginSessionInput schema
+ * @returns Promise
+ * @throws {RepositoryException} If session creation fails or validation fails
+ */
+export async function createLoginSession(
+ _input: z.infer
+): Promise {
+ const _cookies = await cookies();
+ const token = generateRandomString(120);
+ try {
+ const input =
+ await UserSessionInput.createLoginSessionInput.parseAsync(_input);
+ const agent = userAgent(input.request);
+ await sessionRepository.createOne({
+ token,
+ user_id: input.user_id,
+ device: `${agent.os.name} ${agent.browser.name}`,
+ ip: input.request.headers.get("x-forwarded-for") ?? "",
+ last_action_at: new Date(),
+ });
+ _cookies.set(USER_SESSION_KEY.SESSION_TOKEN, token, {
+ path: "/",
+ secure: env.NODE_ENV === "production",
+ httpOnly: true,
+ maxAge: 60 * 60 * 24 * 30,
+ sameSite: "lax",
+ });
+ _cookies.set(USER_SESSION_KEY.SESSION_USER_ID, input.user_id, {
+ path: "/",
+ secure: env.NODE_ENV === "production",
+ httpOnly: true,
+ maxAge: 60 * 60 * 24 * 30,
+ sameSite: "lax",
+ });
+ } catch (error) {
+ handleRepositoryException(error);
+ }
+}
+
+export const validateSessionToken = async (
+ token: string
+): Promise => {
+ const [session] = await persistenceRepository.userSession.findRows({
+ limit: 1,
+ where: eq("token", token),
+ columns: ["id", "user_id", "token", "device"],
+ });
+ if (!session) {
+ return { session: null, user: null };
+ }
+
+ await persistenceRepository.userSession.updateOne({
+ where: eq("id", session.id),
+ data: {
+ last_action_at: new Date(),
+ },
+ });
+
+ const [user] = await persistenceRepository.user.findRows({
+ limit: 1,
+ where: eq("id", session.user_id),
+ columns: ["id", "name", "username", "email", "profile_photo"],
+ });
+
+ return {
+ session: {
+ id: session.id,
+ user_id: session.user_id,
+ token: session.token,
+ },
+ user: {
+ id: user?.id,
+ name: user?.name,
+ username: user?.username,
+ email: user?.email,
+ profile_photo: user?.profile_photo,
+ },
+ };
+};
+
+/**
+ * Get the current session.
+ * @returns - The current session.
+ */
+export const getSession = cache(async (): Promise => {
+ const _cookies = await cookies();
+ const token = _cookies.get(USER_SESSION_KEY.SESSION_TOKEN)?.value ?? null;
+ if (!token) {
+ return { session: null, user: null };
+ }
+ const result = await validateSessionToken(token);
+
+ if (!result) {
+ _cookies.delete(USER_SESSION_KEY.SESSION_TOKEN);
+ }
+
+ return result;
+});
+
+/**
+ * Get the current session user ID.
+ * @returns - The current session user ID.
+ */
+export const getSessionUserId = cache(async (): Promise => {
+ const _cookies = await cookies();
+ const userId = _cookies.get(USER_SESSION_KEY.SESSION_USER_ID)?.value ?? null;
+
+ if (!userId) {
+ throw new Error("Unauthorized");
+ }
+
+ return userId;
+});
+
+export const deleteLoginSession = async () => {
+ const _cookies = await cookies();
+ const token = _cookies.get(USER_SESSION_KEY.SESSION_TOKEN)?.value ?? null;
+ if (!token) {
+ return;
+ }
+
+ try {
+ await persistenceRepository.userSession.deleteRows({
+ where: eq("token", token),
+ });
+ } catch (error) {
+ } finally {
+ _cookies.delete(USER_SESSION_KEY.SESSION_TOKEN);
+ _cookies.delete(USER_SESSION_KEY.SESSION_USER_ID);
+ }
+};
+
+export const deleteSession = async (sessionId: string) => {
+ try {
+ await persistenceRepository.userSession.deleteRows({
+ where: eq("id", sessionId),
+ });
+ } catch (error) {}
+};
+
+export const mySessions = async () => {
+ const _cookies = await cookies();
+ const user_id = _cookies.get(USER_SESSION_KEY.SESSION_USER_ID)?.value ?? null;
+ if (!user_id) {
+ return [];
+ }
+
+ return persistenceRepository.userSession.findRows({
+ where: eq("user_id", user_id),
+ limit: -1,
+ });
+};
+
+/**
+ * Set the URL to redirect to after authentication.
+ * @param url - The URL to redirect to after authentication.
+ */
+export const setAfterAuthRedirect = async (url: string) => {
+ const _cookies = await cookies();
+ _cookies.set("next", url);
+};
+
+/**
+ * Get the URL to redirect to after authentication.
+ * @returns - The URL to redirect to after authentication.
+ */
+export const getAfterAuthRedirect = async () => {
+ const _cookies = await cookies();
+ const value = _cookies.get("next")?.value;
+ return value !== "null" ? value : null;
+};
diff --git a/src/backend/services/user.action.ts b/src/backend/services/user.action.ts
new file mode 100644
index 0000000..233664c
--- /dev/null
+++ b/src/backend/services/user.action.ts
@@ -0,0 +1,138 @@
+"use server";
+
+import { z } from "zod";
+import { User } from "../models/domain-models";
+import { persistenceRepository } from "../persistence-repositories";
+import { desc, eq } from "../persistence/persistence-where-operator";
+import { handleRepositoryException } from "./RepositoryException";
+import { UserRepositoryInput } from "./inputs/user.input";
+
+/**
+ * Updates a user's profile information.
+ *
+ * @param _input - The user profile data to update, validated against UserRepositoryInput.updateUserProfileInput schema
+ * @returns Promise - The updated user
+ * @throws {RepositoryException} If update fails or validation fails
+ */
+export async function updateUserProfile(
+ _input: z.infer
+) {
+ try {
+ const input =
+ await UserRepositoryInput.updateUserProfileInput.parseAsync(_input);
+
+ const updatedUser = await persistenceRepository.user.updateOne({
+ where: eq("id", input.id),
+ data: {
+ name: input.name,
+ username: input.username,
+ email: input.email,
+ profile_photo: input.profile_photo,
+ education: input.education,
+ designation: input.designation,
+ bio: input.bio,
+ website_url: input.websiteUrl,
+ location: input.location,
+ social_links: input.social_links,
+ profile_readme: input.profile_readme,
+ skills: input.skills,
+ },
+ });
+
+ return updatedUser;
+ } catch (error) {
+ handleRepositoryException(error);
+ }
+}
+
+/**
+ * Retrieves a user by their ID.
+ *
+ * @param id - The user's ID
+ * @returns Promise - The user if found, null otherwise
+ * @throws {RepositoryException} If query fails
+ */
+export async function getUserById(id: string): Promise {
+ try {
+ const [user] = await persistenceRepository.user.findRows({
+ where: eq("id", id),
+ limit: 1,
+ });
+ return user;
+ } catch (error) {
+ handleRepositoryException(error);
+ return null;
+ }
+}
+
+/**
+ * Retrieves a user by their username.
+ *
+ * @param username - The user's username
+ * @returns Promise - The user if found, null otherwise
+ * @throws {RepositoryException} If query fails
+ */
+export async function getUserByUsername(
+ username: string,
+ columns?: (keyof User)[]
+): Promise {
+ try {
+ const [user] = await persistenceRepository.user.findRows({
+ where: eq("username", username),
+ limit: 1,
+ columns: columns ? columns : undefined,
+ });
+ return user;
+ } catch (error) {
+ handleRepositoryException(error);
+ return null;
+ }
+}
+
+/**
+ * Retrieves a user by their email.
+ *
+ * @param email - The user's email
+ * @returns Promise - The user if found, null otherwise
+ * @throws {RepositoryException} If query fails
+ */
+export async function getUserByEmail(email: string): Promise {
+ try {
+ const [user] = await persistenceRepository.user.findRows({
+ where: eq("email", email),
+ limit: 1,
+ });
+ return user;
+ } catch (error) {
+ handleRepositoryException(error);
+ return null;
+ }
+}
+
+/**
+ * Gets a paginated list of users.
+ *
+ * @param page - The page number (1-based)
+ * @param limit - Number of users per page
+ * @returns Promise<{users: User[], total: number}> - List of users and total count
+ * @throws {RepositoryException} If query fails
+ */
+export async function getUsers(page: number = 1, limit: number = 10) {
+ try {
+ return persistenceRepository.user.findAllWithPagination({
+ limit,
+ orderBy: [desc("created_at")],
+ columns: [
+ "id",
+ "name",
+ "username",
+ "email",
+ "profile_photo",
+ "created_at",
+ ],
+ });
+ } catch (error) {
+ handleRepositoryException(error);
+ return null;
+ }
+}
diff --git a/src/backend/services/user.repository.ts b/src/backend/services/user.repository.ts
new file mode 100644
index 0000000..3474cd5
--- /dev/null
+++ b/src/backend/services/user.repository.ts
@@ -0,0 +1,72 @@
+"use server";
+
+import { z } from "zod";
+import { User } from "../models/domain-models";
+import { persistenceRepository } from "../persistence-repositories";
+import { and, desc, eq } from "../persistence/persistence-where-operator";
+import { PersistentRepository } from "../persistence/persistence.repository";
+import { handleRepositoryException } from "./RepositoryException";
+import { UserRepositoryInput } from "./inputs/user.input";
+
+const userRepository = new PersistentRepository(
+ "users",
+ persistenceRepository.user
+);
+
+/**
+ * Creates or syncs a user account from a social login provider.
+ * If the user exists, links their social account. If not, creates a new user and social link.
+ *
+ * @param _input - The social user data containing service, uid and profile info, validated against UserRepositoryInput.syncSocialUserInput schema
+ * @returns Promise<{user: User, userSocial: UserSocial}> - The user and their social account link
+ * @throws {RepositoryException} If user creation/sync fails or validation fails
+ */
+export async function bootSocialUser(
+ _input: z.infer
+) {
+ try {
+ const input =
+ await UserRepositoryInput.syncSocialUserInput.parseAsync(_input);
+ let [user] = await userRepository.findRows({
+ where: eq("email", input.email),
+ columns: ["id", "name", "username", "email"],
+ orderBy: [desc("created_at")],
+ limit: 1,
+ });
+
+ if (!user) {
+ user = await userRepository.createOne({
+ name: input.name,
+ username: input.username,
+ email: input.email,
+ profile_photo: input.profile_photo,
+ bio: input.bio ?? "",
+ });
+ }
+
+ // check user has social account
+ const [userSocial] = await persistenceRepository.userSocial.findRows({
+ where: and(
+ eq("service", input.service),
+ eq("service_uid", input.service)
+ ),
+ columns: ["id", "service", "service_uid", "user_id"],
+ limit: 1,
+ });
+
+ if (!userSocial) {
+ await persistenceRepository.userSocial.createOne({
+ service: input.service,
+ service_uid: input.service_uid,
+ user_id: user.id,
+ });
+ }
+
+ return {
+ user,
+ userSocial,
+ };
+ } catch (error) {
+ handleRepositoryException(error);
+ }
+}
diff --git a/src/components/AppImage.tsx b/src/components/AppImage.tsx
index 83999c2..c1aacce 100644
--- a/src/components/AppImage.tsx
+++ b/src/components/AppImage.tsx
@@ -1,9 +1,9 @@
-import { IServerFile } from "@/http/models/AppImage.model";
import { blur } from "@cloudinary/url-gen/actions/effect";
import Image from "next/image";
import { Cloudinary } from "@cloudinary/url-gen";
import React from "react";
+import { IServerFile } from "@/backend/models/domain-models";
interface AppImageProps {
alt?: string;
diff --git a/src/components/ArticleCard.tsx b/src/components/ArticleCard.tsx
new file mode 100644
index 0000000..6dd30f6
--- /dev/null
+++ b/src/components/ArticleCard.tsx
@@ -0,0 +1,163 @@
+"use client";
+
+import { useTranslation } from "@/i18n/use-translation";
+import { formattedTime } from "@/lib/utils";
+import Link from "next/link";
+import React, { useMemo } from "react";
+import { HoverCard, HoverCardContent, HoverCardTrigger } from "./ui/hover-card";
+import UserInformationCard from "./UserInformationCard";
+
+interface ArticleCardProps {
+ id: string;
+ title: string;
+ handle: string;
+ excerpt: string;
+ coverImage?: string;
+ author: {
+ id: string;
+ name: string;
+ avatar: string;
+ username: string;
+ };
+ publishedAt: string;
+ readingTime: number;
+ likes: number;
+ comments: number;
+}
+
+const ArticleCard = ({
+ id,
+ title,
+ handle,
+ excerpt,
+ coverImage,
+ author,
+ publishedAt,
+ readingTime,
+ likes,
+ comments,
+}: ArticleCardProps) => {
+ const { lang } = useTranslation();
+
+ const articleUrl = useMemo(() => {
+ return `/${author.username}/${handle}`;
+ }, [author.username, handle]);
+
+ return (
+
+
+
+
+
+

+
+
+
+ {author.name}
+
+
+
+ ¡
+ {readingTime} min read
+
+
+
+
+
+
+
+
+
+
+
+
+ {title}...
+
+
+ {excerpt} [Read more]
+
+
+
+ {coverImage && (
+
+
+

+
+
+ )}
+
+ {/*
+
+
+
+
+
+
*/}
+
+ );
+};
+
+export default ArticleCard;
diff --git a/src/components/Editor/ArticleEditor.tsx b/src/components/Editor/ArticleEditor.tsx
new file mode 100644
index 0000000..90e35b0
--- /dev/null
+++ b/src/components/Editor/ArticleEditor.tsx
@@ -0,0 +1,301 @@
+"use client";
+
+import { Article } from "@/backend/models/domain-models";
+import * as articleActions from "@/backend/services/article.actions";
+import { useTranslation } from "@/i18n/use-translation";
+import { zodResolver } from "@hookform/resolvers/zod";
+
+import {
+ ArrowLeftIcon,
+ FontBoldIcon,
+ FontItalicIcon,
+ GearIcon,
+ HeadingIcon,
+ ImageIcon,
+} from "@radix-ui/react-icons";
+import React, { useRef } from "react";
+
+import { ArticleRepositoryInput } from "@/backend/services/inputs/article.input";
+import { useAutoResizeTextarea } from "@/hooks/use-auto-resize-textarea";
+import { useClickAway } from "@/hooks/use-click-away";
+import {
+ formattedRelativeTime,
+ formattedTime,
+ zodErrorToString,
+} from "@/lib/utils";
+import { useMutation } from "@tanstack/react-query";
+import clsx from "clsx";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+import EditorCommandButton from "./EditorCommandButton";
+import { useMarkdownEditor } from "./useMarkdownEditor";
+import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
+import { markdownToHtml } from "@/utils/markdoc-parser";
+import { useAppConfirm } from "../app-confirm";
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+ SheetTrigger,
+} from "../ui/sheet";
+import { useToggle } from "@/hooks/use-toggle";
+import ArticleEditorDrawer from "./ArticleEditorDrawer";
+
+interface Prop {
+ uuid?: string;
+ article?: Article;
+}
+
+const ArticleEditor: React.FC = ({ article, uuid }) => {
+ const { _t, lang } = useTranslation();
+ const router = useRouter();
+ const [isOpenSettingDrawer, toggleSettingDrawer] = useToggle();
+ const appConfig = useAppConfirm();
+ const titleRef = useRef(null);
+ const bodyRef = useRef(null);
+ useAutoResizeTextarea(titleRef);
+ const setDebouncedTitle = useDebouncedCallback(() => handleSaveTitle(), 1000);
+ const setDebouncedBody = useDebouncedCallback(() => handleSaveBody(), 1000);
+
+ const [editorMode, selectEditorMode] = React.useState<"write" | "preview">(
+ "write"
+ );
+ const editorForm = useForm({
+ defaultValues: {
+ title: article?.title || "",
+ body: article?.body || "",
+ },
+ resolver: zodResolver(ArticleRepositoryInput.updateArticleInput),
+ });
+
+ const editor = useMarkdownEditor({ ref: bodyRef });
+
+ const updateMyArticleMutation = useMutation({
+ mutationFn: (
+ input: z.infer
+ ) => {
+ return articleActions.updateMyArticle(input);
+ },
+ onSuccess: () => {
+ router.refresh();
+ },
+ onError(err) {
+ alert(err.message);
+ },
+ });
+
+ const articleCreateMutation = useMutation({
+ mutationFn: (
+ input: z.infer
+ ) => articleActions.createMyArticle(input),
+ onSuccess: (res) => {
+ router.push(`/dashboard/articles/${res?.id}`);
+ },
+ onError(err) {
+ alert(err.message);
+ },
+ });
+
+ const handleSaveTitle = () => {
+ if (!uuid) {
+ if (editorForm.watch("title")) {
+ articleCreateMutation.mutate({
+ title: editorForm.watch("title") ?? "",
+ });
+ }
+ }
+
+ if (uuid) {
+ if (editorForm.watch("title")) {
+ updateMyArticleMutation.mutate({
+ article_id: uuid,
+ title: editorForm.watch("title") ?? "",
+ });
+ }
+ }
+ };
+
+ const handleSaveBody = () => {
+ // if (!uuid) {
+ // if (editorForm.watch("body")) {
+ // articleCreateMutation.mutate({
+ // title: editorForm.watch("body") ?? "",
+ // });
+ // }
+ // }
+
+ if (uuid) {
+ if (editorForm.watch("body")) {
+ updateMyArticleMutation.mutate({
+ article_id: uuid,
+ body: editorForm.watch("body") ?? "",
+ });
+ }
+ }
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+ {updateMyArticleMutation.isPending ? (
+
{_t("Saving")}...
+ ) : (
+
+ {article?.updated_at && (
+
+ ({_t("Saved")} {formattedTime(article?.updated_at, lang)})
+
+ )}
+
+ )}
+
+
+ {uuid && (
+
+ {article?.is_published ? (
+ {_t("Published")}
+ ) : (
+ {_t("Draft")}
+ )}
+
+ )}
+
+
+ {uuid && (
+
+
+
+
+
+
+ )}
+
+
+ {/* Editor */}
+
+
+
+ {uuid && (
+
+ )}
+ >
+ );
+};
+
+export default ArticleEditor;
diff --git a/src/components/Editor/ArticleEditorDrawer.tsx b/src/components/Editor/ArticleEditorDrawer.tsx
new file mode 100644
index 0000000..c4a49b4
--- /dev/null
+++ b/src/components/Editor/ArticleEditorDrawer.tsx
@@ -0,0 +1,233 @@
+"use client";
+
+import { Article } from "@/backend/models/domain-models";
+import * as articleActions from "@/backend/services/article.actions";
+import { ArticleRepositoryInput } from "@/backend/services/inputs/article.input";
+import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
+import { useTranslation } from "@/i18n/use-translation";
+import { useSession } from "@/store/session.atom";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useMutation } from "@tanstack/react-query";
+import { LinkIcon } from "lucide-react";
+import { useRouter } from "next/navigation";
+import React from "react";
+import { SubmitHandler, useForm } from "react-hook-form";
+import { z } from "zod";
+import { Button } from "../ui/button";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "../ui/form";
+import { Input } from "../ui/input";
+import { InputTags } from "../ui/input-tags";
+import { Sheet, SheetContent } from "../ui/sheet";
+import { TagInput } from "../ui/tags-input";
+import { Textarea } from "../ui/textarea";
+
+interface Props {
+ article: Article;
+ open: boolean;
+ onClose: () => void;
+ onSave: () => void;
+}
+const ArticleEditorDrawer: React.FC = ({ article, open, onClose }) => {
+ const session = useSession();
+ const { _t } = useTranslation();
+ const router = useRouter();
+ const setDebounceHandler = useDebouncedCallback(async (slug: string) => {
+ const handle = await articleActions.getUniqueArticleHandle(slug);
+ form.setValue("handle", handle);
+ }, 2000);
+ const updateMyArticleMutation = useMutation({
+ mutationFn: (
+ input: z.infer
+ ) => {
+ return articleActions.updateMyArticle(input);
+ },
+ onSuccess: () => {
+ router.refresh();
+ },
+ onError(err) {
+ alert(err.message);
+ },
+ });
+
+ const [tags, setTags] = React.useState([]);
+
+ const form = useForm<
+ z.infer
+ >({
+ defaultValues: {
+ article_id: article.id,
+ handle: article?.handle ?? "",
+ excerpt: article?.excerpt ?? "",
+ metadata: {
+ seo: {
+ title: article?.metadata?.seo?.title ?? "",
+ description: article?.metadata?.seo?.description ?? "",
+ keywords: article?.metadata?.seo?.keywords ?? [],
+ canonical_url: article?.metadata?.seo?.canonical_url ?? "",
+ },
+ },
+ },
+ resolver: zodResolver(ArticleRepositoryInput.updateMyArticleInput),
+ });
+
+ const handleOnSubmit: SubmitHandler<
+ z.infer
+ > = (payload) => {
+ updateMyArticleMutation.mutate({
+ article_id: article?.id ?? "",
+ excerpt: payload.excerpt,
+ handle: payload.handle,
+ metadata: {
+ seo: {
+ title: payload.metadata?.seo?.title ?? "",
+ description: payload.metadata?.seo?.description ?? "",
+ keywords: payload.metadata?.seo?.keywords ?? [],
+ canonical_url: payload.metadata?.seo?.canonical_url ?? "",
+ },
+ },
+ });
+ };
+ //className="m-3 h-[100vh-20px] w-[100vw-20px]"
+ return (
+
+
+
+
+
+
+
+
+ );
+};
+
+export default ArticleEditorDrawer;
diff --git a/src/app/(dashboard-editor)/dashboard/articles/_components/EditorCommandButton.tsx b/src/components/Editor/EditorCommandButton.tsx
similarity index 100%
rename from src/app/(dashboard-editor)/dashboard/articles/_components/EditorCommandButton.tsx
rename to src/components/Editor/EditorCommandButton.tsx
diff --git a/src/components/Editor/useMarkdownEditor.ts b/src/components/Editor/useMarkdownEditor.ts
new file mode 100644
index 0000000..4917122
--- /dev/null
+++ b/src/components/Editor/useMarkdownEditor.ts
@@ -0,0 +1,63 @@
+import { useRef, useState } from "react";
+
+type MarkdownCommand =
+ | "heading"
+ | "bold"
+ | "italic"
+ | "link"
+ | "bold"
+ | "code"
+ | "image";
+
+interface Options {
+ value?: string;
+ ref?: React.RefObject;
+}
+
+export function useMarkdownEditor(options?: Options) {
+ const textareaRef = options?.ref;
+ if (!textareaRef) return;
+
+ const executeCommand = (command: MarkdownCommand) => {
+ if (!textareaRef.current) return;
+ const { selectionStart, selectionEnd } = textareaRef.current;
+ let updatedValue = textareaRef.current.value;
+
+ console.log({ updatedValue, selectionStart, selectionEnd });
+
+ switch (command) {
+ case "heading":
+ updatedValue =
+ updatedValue.substring(0, selectionStart) +
+ `## ${updatedValue.substring(selectionStart, selectionEnd)}` +
+ updatedValue.substring(selectionEnd);
+ break;
+ case "bold":
+ updatedValue =
+ updatedValue.substring(0, selectionStart) +
+ `**${updatedValue.substring(selectionStart, selectionEnd)}**` +
+ updatedValue.substring(selectionEnd);
+ break;
+ case "italic":
+ updatedValue =
+ updatedValue.substring(0, selectionStart) +
+ `*${updatedValue.substring(selectionStart, selectionEnd)}*` +
+ updatedValue.substring(selectionEnd);
+ break;
+ case "image":
+ updatedValue =
+ updatedValue.substring(0, selectionStart) +
+ `` +
+ updatedValue.substring(selectionEnd);
+ break;
+ }
+ textareaRef.current.value = updatedValue;
+ // Trigger input event to notify changes
+ const event = new Event("change", { bubbles: true });
+ textareaRef.current.dispatchEvent(event);
+
+ textareaRef.current.focus();
+ };
+
+ return { executeCommand };
+}
diff --git a/src/components/ErrorPage.module.css b/src/components/ErrorPage.module.css
deleted file mode 100644
index d70a223..0000000
--- a/src/components/ErrorPage.module.css
+++ /dev/null
@@ -1,37 +0,0 @@
-.root {
- padding-top: rem(80px);
- padding-bottom: rem(80px);
-}
-
-.label {
- text-align: center;
- font-weight: 900;
- font-size: rem(38px);
- line-height: 1;
- margin-bottom: calc(1.5 * var(--mantine-spacing-xl));
- color: var(--forground);
-
- @media (max-width: $mantine-breakpoint-sm) {
- font-size: rem(32px);
- }
-}
-
-.description {
- max-width: rem(500px);
- margin: auto;
- margin-top: var(--mantine-spacing-xl);
- margin-bottom: calc(1.5 * var(--mantine-spacing-xl));
-}
-
-.title {
- font-family:
- Greycliff CF,
- var(--mantine-font-family);
- text-align: center;
- font-weight: 900;
- font-size: rem(38px);
-
- @media (max-width: $mantine-breakpoint-sm) {
- font-size: rem(32px);
- }
-}
\ No newline at end of file
diff --git a/src/components/ErrorPage.tsx b/src/components/ErrorPage.tsx
deleted file mode 100644
index 67c234f..0000000
--- a/src/components/ErrorPage.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-import { Title, Text, Button, Container, Group } from "@mantine/core";
-import classes from "./ErrorPage.module.css";
-
-import React from "react";
-
-interface ErrorPageProps {
- error: Error & { digest?: string };
- reset: () => void;
-}
-
-const ErrorPage: React.FC = ({ error }) => {
- return (
-
- {error?.message} đ§
-
-
-
-
- );
-};
-
-export default ErrorPage;
diff --git a/src/components/FakeEditor.tsx b/src/components/FakeEditor.tsx
deleted file mode 100644
index 3d43ed1..0000000
--- a/src/components/FakeEditor.tsx
+++ /dev/null
@@ -1,16 +0,0 @@
-import _t from "@/i18n/_t";
-import React from "react";
-
-const FakeEditor = () => {
- return (
-
-
-
-
{_t("Write new diary đ")}
-
- );
-};
-
-export default FakeEditor;
diff --git a/src/components/Navbar/AuthenticatedUserMenu.tsx b/src/components/Navbar/AuthenticatedUserMenu.tsx
new file mode 100644
index 0000000..bd80ab5
--- /dev/null
+++ b/src/components/Navbar/AuthenticatedUserMenu.tsx
@@ -0,0 +1,59 @@
+"use client";
+
+import { useTranslation } from "@/i18n/use-translation";
+import { useSession } from "@/store/session.atom";
+import { useAppConfirm } from "../app-confirm";
+import * as sessionActions from "@/backend/services/session.actions";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "../ui/dropdown-menu";
+import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
+import Link from "next/link";
+
+const AuthenticatedUserMenu = () => {
+ const { _t } = useTranslation();
+ const authSession = useSession();
+ const appConfirm = useAppConfirm();
+
+ const handleLogout = async () => {
+ appConfirm.show({
+ title: _t("Sure to logout?"),
+ children: {_t("You will be logged out after this")}
,
+ async onConfirm() {
+ await sessionActions.deleteLoginSession();
+ window.location.reload();
+ },
+ });
+ };
+
+ return (
+
+
+
+
+ {authSession?.user?.name.charAt(0)}
+
+
+
+
+
+ {_t("My profile")}
+
+
+
+ {_t("Dashboard")}
+
+ {_t("Bookmarks")}
+ {_t("Settings")}
+
+ {_t("Logout")}
+
+
+
+ );
+};
+
+export default AuthenticatedUserMenu;
diff --git a/src/components/Navbar/LanguageSwitcher.tsx b/src/components/Navbar/LanguageSwitcher.tsx
new file mode 100644
index 0000000..ff8e720
--- /dev/null
+++ b/src/components/Navbar/LanguageSwitcher.tsx
@@ -0,0 +1,48 @@
+"use client";
+
+import { useTranslation } from "@/i18n/use-translation";
+import React from "react";
+
+const LanguageSwitcher = () => {
+ const { toggle, lang } = useTranslation();
+
+ return (
+
+ );
+};
+
+export default LanguageSwitcher;
diff --git a/src/components/Navbar/Navbar.tsx b/src/components/Navbar/Navbar.tsx
new file mode 100644
index 0000000..8fc3337
--- /dev/null
+++ b/src/components/Navbar/Navbar.tsx
@@ -0,0 +1,31 @@
+import Link from "next/link";
+import TechdiaryLogo from "../logos/TechdiaryLogo";
+
+import SearchInput from "./SearchInput";
+import NavbarActions from "./NavbarActions";
+
+interface NavbarProps {
+ Trailing?: React.ReactNode;
+}
+
+const Navbar: React.FC = async ({ Trailing }) => {
+ return (
+
+
+
+ {Trailing}
+
+
+
+ Techdiary
+
+
+
+
+
+
+
+ );
+};
+
+export default Navbar;
diff --git a/src/components/Navbar/NavbarActions.tsx b/src/components/Navbar/NavbarActions.tsx
new file mode 100644
index 0000000..5e8f8d4
--- /dev/null
+++ b/src/components/Navbar/NavbarActions.tsx
@@ -0,0 +1,57 @@
+"use client";
+
+import { useTranslation } from "@/i18n/use-translation";
+
+import { Button } from "../ui/button";
+import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
+import ThemeSwitcher from "./ThemeSwitcher";
+
+import { useSession } from "@/store/session.atom";
+import { SearchIcon } from "lucide-react";
+import SocialLoginCard from "../SocialLoginCard";
+import AuthenticatedUserMenu from "./AuthenticatedUserMenu";
+import LanguageSwitcher from "./LanguageSwitcher";
+import Link from "next/link";
+
+const NavbarActions: React.FC = () => {
+ const { _t } = useTranslation();
+ const authSession = useSession();
+
+ return (
+
+
+
+
+
+ {authSession?.session ? (
+ <>
+
+
+ >
+ ) : (
+
+ )}
+
+ );
+};
+
+export default NavbarActions;
+
+const UnAuthenticatedMenu = () => {
+ const { _t } = useTranslation();
+
+ return (
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/components/Navbar/SearchInput.tsx b/src/components/Navbar/SearchInput.tsx
new file mode 100644
index 0000000..8b3ddf7
--- /dev/null
+++ b/src/components/Navbar/SearchInput.tsx
@@ -0,0 +1,18 @@
+import _t from "@/i18n/_t";
+
+const SearchInput = async () => {
+ return (
+
+ );
+};
+
+export default SearchInput;
diff --git a/src/components/Navbar/ThemeSwitcher.tsx b/src/components/Navbar/ThemeSwitcher.tsx
new file mode 100644
index 0000000..d5b871c
--- /dev/null
+++ b/src/components/Navbar/ThemeSwitcher.tsx
@@ -0,0 +1,70 @@
+"use client";
+
+import { useTranslation } from "@/i18n/use-translation";
+import { LightbulbIcon, MonitorIcon, MoonIcon } from "lucide-react";
+import { useTheme } from "next-themes";
+import React from "react";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "../ui/dropdown-menu";
+import { Button } from "../ui/button";
+
+const ThemeSwitcher = () => {
+ const { _t } = useTranslation();
+ const { theme, setTheme } = useTheme();
+ const [mounted, setMounted] = React.useState(false);
+
+ React.useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ if (!mounted) {
+ return null;
+ }
+
+ const renderIcon = () => {
+ switch (theme) {
+ case "light":
+ return ;
+ case "dark":
+ return ;
+ case "system":
+ return ;
+ default:
+ return ;
+ }
+ };
+
+ return (
+ <>
+
+
+
+
+
+ {_t("Color Theme")}
+
+ setTheme("system")}>
+
+ {_t("System")}
+
+ setTheme("light")}>
+
+ {_t("Light")}
+
+ setTheme("dark")}>
+
+ {_t("Dark")}
+
+
+
+ >
+ );
+};
+
+export default ThemeSwitcher;
diff --git a/src/components/SocialLoginCard.tsx b/src/components/SocialLoginCard.tsx
new file mode 100644
index 0000000..942722d
--- /dev/null
+++ b/src/components/SocialLoginCard.tsx
@@ -0,0 +1,78 @@
+"use client";
+
+import { useTranslation } from "@/i18n/use-translation";
+import Link from "next/link";
+import { useSearchParams } from "next/navigation";
+
+const SocialLoginCard = () => {
+ const { _t } = useTranslation();
+ const searchParams = useSearchParams();
+
+ return (
+
+
+
+
{_t("Login with Github")}
+
+ {/*
*/}
+
+ {/*
+
+ {_t("Login using email")}
+
+
*/}
+
+ );
+};
+
+export default SocialLoginCard;
diff --git a/src/components/UnsplashImageGallery.tsx b/src/components/UnsplashImageGallery.tsx
deleted file mode 100644
index 2cbd6c2..0000000
--- a/src/components/UnsplashImageGallery.tsx
+++ /dev/null
@@ -1,129 +0,0 @@
-import { IServerFile } from "@/http/models/AppImage.model";
-import { useTranslation } from "@/i18n/use-translation";
-import { Image, Input, LoadingOverlay, Skeleton, Title } from "@mantine/core";
-import { useDebouncedState } from "@mantine/hooks";
-import { showNotification } from "@mantine/notifications";
-import { UploadIcon } from "@radix-ui/react-icons";
-import { useMutation, useQuery } from "@tanstack/react-query";
-import axios from "axios";
-
-interface IProp {
- onUploadImage: (image: IServerFile) => void;
-}
-
-const UnsplashImageGallery: React.FC = ({ onUploadImage }) => {
- const { _t } = useTranslation();
- const [q, setQ] = useDebouncedState("", 500);
- const { data, isLoading } = useQuery({
- queryKey: ["unsplash", q],
- queryFn: async () => {
- const res = await axios.get("https://api.unsplash.com/search/photos", {
- params: {
- client_id: "k0j9Wqxzzexx-YpdFUDb1z8wVYbIy7T_6gjzJi3NpRg",
- query: q || "article",
- per_page: 100,
- // orientation: "landscape",
- },
- });
-
- return (res?.data?.results as IUnsplashImage[]) || [];
- },
- });
-
- const mutation = useMutation({
- mutationFn: async (url: string) => {
- return axios.post(
- `${process.env.NEXT_PUBLIC_APP_URL}/api/cloudinary/upload-by-url`,
- { url, folder: "landscape" }
- ) as any;
- },
- onSuccess: (data) => {
- onUploadImage({
- key: data?.data?.public_id || "",
- provider: "cloudinary",
- });
- },
- onError: () => {
- showNotification({
- message: _t("Failed to upload image"),
- color: "red",
- });
- },
- });
-
- return (
-
-
-
-
{_t("Pick cover from unsplash")}
- setQ(e.target.value)}
- />
-
-
-
- {data?.map((image) => (
-
mutation.mutate(image.urls.regular)}
- className="relative mb-6 rounded-lg overflow-hidden group cursor-pointer"
- >
-
-
-
-
-
- ))}
-
- {isLoading &&
- Array(30)
- .fill(0)
- .map((_, i) => (
-
-
-
- ))}
-
-
-
- );
-};
-
-export default UnsplashImageGallery;
-
-export interface IUnsplashImage {
- id: string;
- slug: string;
- created_at: Date;
- updated_at: Date;
- promoted_at: null;
- width: number;
- height: number;
- color: string;
- blur_hash: string;
- description: null | string;
- alt_description: string;
- urls: UnsplashImageUrls;
- likes: number;
-}
-
-interface UnsplashImageUrls {
- raw: string;
- full: string;
- regular: string;
- small: string;
- thumb: string;
- small_s3: string;
-}
diff --git a/src/components/UserHoverCard.tsx b/src/components/UserHoverCard.tsx
deleted file mode 100644
index 8238b25..0000000
--- a/src/components/UserHoverCard.tsx
+++ /dev/null
@@ -1,54 +0,0 @@
-import { UserReference } from "@/http/models/User.model";
-import { Image, Paper } from "@mantine/core";
-import Link from "next/link";
-import React from "react";
-import { AiFillGithub, AiFillTwitterCircle } from "react-icons/ai";
-
-interface Props {
- user: UserReference;
-}
-
-const UserHoverCard: React.FC = ({ user }) => {
- return (
-
-
-
-
-
-
- {user.name}
-
-
- @{user.username}
-
-
- {user?.social_links?.github && (
-
-
-
- )}
-
- {user.social_links.twitter && (
-
-
-
- )}
-
-
-
-
- );
-};
-
-export default UserHoverCard;
diff --git a/src/components/UserInformationCard.tsx b/src/components/UserInformationCard.tsx
new file mode 100644
index 0000000..7413384
--- /dev/null
+++ b/src/components/UserInformationCard.tsx
@@ -0,0 +1,105 @@
+"use client";
+
+import { useTranslation } from "@/i18n/use-translation";
+import { Button } from "./ui/button";
+import * as userActions from "@/backend/services/user.action";
+import { useQuery } from "@tanstack/react-query";
+import { useSession } from "@/store/session.atom";
+import Link from "next/link";
+
+interface Props {
+ userId: string;
+}
+
+const UserInformationCard: React.FC = ({ userId }) => {
+ const { _t } = useTranslation();
+ const session = useSession();
+ const userQuery = useQuery({
+ queryKey: ["user", userId],
+ queryFn: () => userActions.getUserById(userId),
+ });
+ const profileData = {
+ avatarUrl: userQuery.data?.profile_photo,
+ name: userQuery.data?.name,
+ title: userQuery.data?.designation,
+ bio: userQuery.data?.bio,
+ location: userQuery.data?.location,
+ joinDate: userQuery.data?.created_at,
+ education: userQuery.data?.education,
+ };
+
+ if (userQuery.isFetching)
+ return (
+ <>
+
+ >
+ );
+
+ return (
+
+ {/* Profile Header */}
+
+ {/* Avatar */}
+
+

+
+
+ {/* Name */}
+
+
{profileData.name}
+
kingrayhan
+
+
+
+ {/* Profile Body */}
+
+ {/* Edit Button */}
+ {session?.user?.id == userId ? (
+
+ ) : (
+
+ )}
+
+ {/* Bio */}
+
+ {profileData.bio}
+
+
+ {/* Profile Details */}
+
+ {/* Location */}
+ {profileData.location && (
+
+
{_t("Location")}
+
+ {profileData.location}
+
+
+ )}
+
+ {profileData.education && (
+
+
{_t("Education")}
+
+ {profileData.education}
+
+
+ )}
+
+
+
+ );
+};
+
+export default UserInformationCard;
diff --git a/src/components/VisibilitySensor.tsx b/src/components/VisibilitySensor.tsx
new file mode 100644
index 0000000..6d838b4
--- /dev/null
+++ b/src/components/VisibilitySensor.tsx
@@ -0,0 +1,49 @@
+"use client";
+
+import { LoaderIcon } from "lucide-react";
+import React, { useEffect } from "react";
+
+interface VisibilitySensorProps {
+ visible?: boolean;
+ onLoadmore?: () => void;
+}
+
+const VisibilitySensor: React.FC = ({
+ visible = true,
+ onLoadmore,
+}) => {
+ const ref = React.useRef(null);
+
+ useEffect(() => {
+ const observer = new IntersectionObserver(
+ (entries) => {
+ entries.forEach((entry) => {
+ if (entry.isIntersecting) {
+ onLoadmore?.();
+ }
+ });
+ },
+ {
+ root: null, // Uses viewport as root
+ rootMargin: "0px",
+ threshold: 1,
+ }
+ );
+ if (ref.current) {
+ observer.observe(ref.current);
+ }
+
+ return () => {
+ observer.disconnect();
+ };
+ }, [ref, onLoadmore]);
+
+ if (!visible) return null;
+ return (
+
+
+
+ );
+};
+
+export default VisibilitySensor;
diff --git a/src/components/action.ts b/src/components/action.ts
deleted file mode 100644
index e127450..0000000
--- a/src/components/action.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-"use server";
-
-import { cookies } from "next/headers";
-
-export const logout = async () => {
- // await ssrHttp.post("api/auth/logout");
- cookies().delete("XSRF-TOKEN");
-};
diff --git a/src/components/app-alert.tsx b/src/components/app-alert.tsx
new file mode 100644
index 0000000..15f37bc
--- /dev/null
+++ b/src/components/app-alert.tsx
@@ -0,0 +1,155 @@
+"use client";
+import { CircleX } from "lucide-react";
+import React, {
+ createContext,
+ useCallback,
+ useContext,
+ useMemo,
+ useState,
+} from "react";
+import {
+ AlertDialog,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "./ui/alert-dialog";
+import { useTranslation } from "@/i18n/use-translation";
+
+interface Options {
+ title: string;
+ description?: string;
+ type?: "warning" | "error" | "success" | "info";
+ isShowCancel?: boolean;
+ isShowConfirm?: boolean;
+}
+
+interface ContextType {
+ show: (options: Options) => void;
+ closeModal: () => void;
+}
+
+const Context = createContext(undefined);
+
+export function AppAlertProvider({ children }: { children: React.ReactNode }) {
+ const { _t } = useTranslation();
+ const [modalContent, setModalContent] = useState(null);
+ const [isOpen, setIsOpen] = useState(false);
+
+ const show = useCallback((options: Options) => {
+ const type = options.type ?? "error";
+ setModalContent({
+ ...options,
+ type,
+ });
+ setIsOpen(true);
+ }, []);
+
+ const closeModal = useCallback(() => {
+ setIsOpen(false);
+ setModalContent(null);
+ }, []);
+
+ const renderIcon = useMemo(() => {
+ switch (modalContent?.type) {
+ case "error":
+ return ;
+ case "warning":
+ return (
+
+ );
+ case "success":
+ return (
+
+ );
+ case "info":
+ return (
+
+ );
+ default:
+ return null;
+ }
+ }, [modalContent?.type]);
+
+ return (
+
+ {children}
+ {modalContent && (
+
+
+
+
+ {/* {modalContent.title ?? "Are you sure?"} */}
+
+
+
+
+ {renderIcon}
+
{modalContent.title}
+ {modalContent.description && (
+
+ {modalContent?.description}
+
+ )}
+
+
+
+
+ {_t("Close")}
+
+
+
+
+ )}
+
+ );
+}
+
+export function useAppAlert() {
+ const context = useContext(Context);
+ if (context === undefined) {
+ throw new Error("useAppAlert must be used within a AppAlertProvider");
+ }
+ return context;
+}
diff --git a/src/components/app-confirm.tsx b/src/components/app-confirm.tsx
new file mode 100644
index 0000000..4a51d8e
--- /dev/null
+++ b/src/components/app-confirm.tsx
@@ -0,0 +1,106 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import React, { createContext, useCallback, useContext, useState } from "react";
+import {
+ AlertDialog,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "./ui/alert-dialog";
+
+interface Options {
+ title: string;
+ children?: React.ReactNode;
+ labels?: {
+ confirm?: string;
+ cancel?: string;
+ };
+ onCancel?: () => void;
+ onConfirm?: () => void;
+ isShowCancel?: boolean;
+ isShowConfirm?: boolean;
+}
+
+interface ContextType {
+ show: (options: Options) => void;
+ closeModal: () => void;
+}
+
+const Context = createContext(undefined);
+
+export function AppConfirmProvider({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ const [modalContent, setModalContent] = useState(null);
+ const [isOpen, setIsOpen] = useState(false);
+
+ const show = useCallback((options: Options) => {
+ setModalContent({
+ ...options,
+ isShowCancel: options.isShowCancel ?? true,
+ isShowConfirm: options.isShowConfirm ?? true,
+ });
+ setIsOpen(true);
+ }, []);
+
+ const closeModal = useCallback(() => {
+ setIsOpen(false);
+ setModalContent(null);
+ }, []);
+
+ return (
+
+ {children}
+ {modalContent && (
+
+
+
+
+ {modalContent.title ?? "Are you sure?"}
+
+
+
+ {modalContent.children}
+
+
+ {modalContent.isShowCancel && (
+
+
+
+ )}
+
+ {modalContent.isShowConfirm && (
+
+ )}
+
+
+
+ )}
+
+ );
+}
+
+export function useAppConfirm() {
+ const context = useContext(Context);
+ if (context === undefined) {
+ throw new Error("useAppConfirm must be used within a ModalProvider");
+ }
+ return context;
+}
diff --git a/src/components/asides/HomeLeftSidebar.tsx b/src/components/asides/HomeLeftSidebar.tsx
deleted file mode 100644
index d67ab46..0000000
--- a/src/components/asides/HomeLeftSidebar.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import _t from "@/i18n/_t";
-import { AiOutlineHome } from "react-icons/ai";
-import { HiOutlineBookmark, HiPlus } from "react-icons/hi";
-import TagsWidget from "./widgets/TagsWidget";
-import Link from "next/link";
-
-const HomeLeftSidebar = () => {
- return (
-
-
-
-
-
{_t("Home")}
-
-
-
-
{_t("Bookmarks")}
-
-
-
-
{_t("New diary")}
-
-
-
-
-
- );
-};
-
-export default HomeLeftSidebar;
diff --git a/src/components/asides/HomeRightSidebar.tsx b/src/components/asides/HomeRightSidebar.tsx
deleted file mode 100644
index f51369d..0000000
--- a/src/components/asides/HomeRightSidebar.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import DiscordWidget from "./widgets/DiscordWidget";
-import ImportantLinksWidget from "./widgets/ImportantLinksWidget";
-import LatestUsers from "./widgets/LatestUsers";
-import SocialLinksWidget from "./widgets/SocialLinksWidget";
-import SocialLogin from "./widgets/SocialLogin";
-
-const HomeRightSidebar = () => {
- return (
-
-
-
-
-
-
-
- );
-};
-
-export default HomeRightSidebar;
diff --git a/src/components/asides/widgets/LatestUsers.tsx b/src/components/asides/widgets/LatestUsers.tsx
deleted file mode 100644
index 7dfdc4d..0000000
--- a/src/components/asides/widgets/LatestUsers.tsx
+++ /dev/null
@@ -1,84 +0,0 @@
-import { http } from "@/http/http.client";
-import { PaginatedResponse } from "@/http/models/PaginatedResponse.model";
-import _t from "@/i18n/_t";
-import { relativeTime } from "@/utils/relativeTime";
-// import { relativeTime } from "@/utils/relativeTime";
-import Link from "next/link";
-
-interface IUser {
- id: string;
- name: string | null;
- username: string | null;
- profilePhoto: string | null;
- social_links: {
- twitter: string | null;
- github: string | null;
- };
- joined: Date;
-}
-
-const LatestUsers = async () => {
- const { data } = await http.get>(
- "/api/profile/list"
- );
-
- return (
-
-
- {_t("Latest registered users")}
-
-
- {/* {isFetching &&
- Array.from({ length: 10 }).map((_, i) =>
)} */}
-
- {/*
{JSON.stringify(data, null, 2)} */}
- {data.data.map((user) => (
-
- ))}
-
-
- );
-};
-
-export default LatestUsers;
-
-const User = ({ user }: { user: IUser }) => (
-
-
-
-

-
-
-
-
-
-
- {user.name}
-
-
-
- {relativeTime(new Date(user.joined))}
-
-
-
-);
-
-// const UserSkeleton = () => {
-// return (
-//
-// );
-// };
diff --git a/src/components/asides/widgets/SocialLogin.tsx b/src/components/asides/widgets/SocialLogin.tsx
deleted file mode 100644
index ef7c0bd..0000000
--- a/src/components/asides/widgets/SocialLogin.tsx
+++ /dev/null
@@ -1,172 +0,0 @@
-"use client";
-
-import { useTranslation } from "@/i18n/use-translation";
-import { userAtom } from "@/store/user.atom";
-import { BookmarkIcon, DashboardIcon, PersonIcon } from "@radix-ui/react-icons";
-import { useAtomValue } from "jotai";
-import Link from "next/link";
-import React from "react";
-
-const SocialLogin = () => {
- const { _t } = useTranslation();
- const currentUser = useAtomValue(userAtom);
- const [loadingGithub, setLoadingGithub] = React.useState(false);
- const [loadingGoogle, setLoadingGoogle] = React.useState(false);
-
- const socialLogin = (service: "github" | "google") => {
- localStorage.setItem("redirect_uri", window.location.href);
- if (service == "google") {
- setLoadingGoogle(true);
- }
-
- if (service == "github") {
- setLoadingGithub(true);
- }
- window.location.href = `${process.env.NEXT_PUBLIC_API_URL}/api/oauth/${service}`;
- };
-
- if (currentUser) {
- return (
-
-
-

-
-
-
- {currentUser?.name}
-
-
- @{currentUser?.username}
- {" "}
-
-
-
-
-
-
-
{_t("My profile")}
-
-
-
-
{_t("Dashboard")}
-
-
-
-
-
{_t("Bookmarks")}
-
-
-
- );
- }
-
- if (!currentUser)
- return (
-
-
-
-
-
-
- {_t("Login using email")}
-
-
-
- );
-};
-
-export default SocialLogin;
diff --git a/src/components/icons/Dribbble.tsx b/src/components/icons/Dribbble.tsx
new file mode 100644
index 0000000..b188ad4
--- /dev/null
+++ b/src/components/icons/Dribbble.tsx
@@ -0,0 +1,31 @@
+import React from "react";
+
+interface Props {
+ size?: number;
+}
+
+const Dribbble: React.FC = ({ size = 30 }) => {
+ return (
+
+ );
+};
+
+export default Dribbble;
diff --git a/src/components/icons/behance.tsx b/src/components/icons/behance.tsx
new file mode 100644
index 0000000..7d8c018
--- /dev/null
+++ b/src/components/icons/behance.tsx
@@ -0,0 +1,27 @@
+import React from "react";
+
+interface Props {
+ size?: number;
+}
+const Behance: React.FC = ({ size = 256 }) => {
+ return (
+
+ );
+};
+
+export default Behance;
diff --git a/src/components/icons/facebook.tsx b/src/components/icons/facebook.tsx
new file mode 100644
index 0000000..51a0be7
--- /dev/null
+++ b/src/components/icons/facebook.tsx
@@ -0,0 +1,31 @@
+import React from "react";
+
+interface Props {
+ size?: number;
+ className?: string;
+}
+const Facebook: React.FC = ({ size = 256, className }) => {
+ return (
+
+ );
+};
+
+export default Facebook;
diff --git a/src/components/icons/github.tsx b/src/components/icons/github.tsx
new file mode 100644
index 0000000..01fadda
--- /dev/null
+++ b/src/components/icons/github.tsx
@@ -0,0 +1,26 @@
+import React from "react";
+interface Props {
+ size?: number;
+}
+const Github: React.FC = ({ size = 256 }) => {
+ return (
+
+ );
+};
+
+export default Github;
diff --git a/src/components/icons/instagram.tsx b/src/components/icons/instagram.tsx
new file mode 100644
index 0000000..05aa282
--- /dev/null
+++ b/src/components/icons/instagram.tsx
@@ -0,0 +1,28 @@
+import React from "react";
+
+interface Props {
+ size?: number;
+}
+
+const Instagram: React.FC = ({ size = 256 }) => {
+ return (
+