diff --git a/.gitignore b/.gitignore
index 0eb6f40..1a632ba 100644
--- a/.gitignore
+++ b/.gitignore
@@ -38,3 +38,4 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
+.vscode/mcp.json
\ No newline at end of file
diff --git a/package.json b/package.json
index e888f10..435537b 100644
--- a/package.json
+++ b/package.json
@@ -14,6 +14,9 @@
"dependencies": {
"@cloudinary/react": "^1.14.1",
"@cloudinary/url-gen": "^1.21.0",
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
"@hookform/error-message": "^2.0.1",
"@hookform/resolvers": "^5.0.0",
"@markdoc/markdoc": "^0.5.1",
@@ -37,6 +40,7 @@
"cloudinary": "^2.6.0",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
+ "date-fns": "^4.1.0",
"dotenv": "^16.4.7",
"drizzle-orm": "^0.41.0",
"jotai": "^2.12.2",
diff --git a/src/app/(home)/_components/ArticleFeed.tsx b/src/app/(home)/_components/ArticleFeed.tsx
index 64e91e6..983d91b 100644
--- a/src/app/(home)/_components/ArticleFeed.tsx
+++ b/src/app/(home)/_components/ArticleFeed.tsx
@@ -1,15 +1,22 @@
"use client";
import * as articleActions from "@/backend/services/article.actions";
+import * as seriesActions from "@/backend/services/series.action";
import ArticleCard from "@/components/ArticleCard";
+import SeriesCard from "@/components/SeriesCard";
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import VisibilitySensor from "@/components/VisibilitySensor";
import { readingTime } from "@/lib/utils";
import getFileUrl from "@/utils/getFileUrl";
import { useInfiniteQuery } from "@tanstack/react-query";
+import { Loader } from "lucide-react";
+import { useState } from "react";
const ArticleFeed = () => {
- const feedInfiniteQuery = useInfiniteQuery({
- queryKey: ["article-feed-2"],
+ const [feedType, setFeedType] = useState<"articles" | "series">("articles");
+
+ const articleFeedQuery = useInfiniteQuery({
+ queryKey: ["article-feed", feedType],
queryFn: ({ pageParam }) =>
articleActions.articleFeed({ limit: 5, page: pageParam }),
initialPageParam: 1,
@@ -18,55 +25,115 @@ const ArticleFeed = () => {
const _page = lastPage?.meta?.currentPage ?? 1;
return _page + 1;
},
+ enabled: feedType === "articles",
+ });
+
+ const seriesFeedQuery = useInfiniteQuery({
+ queryKey: ["series-feed", feedType],
+ queryFn: ({ pageParam }) =>
+ seriesActions.seriesFeed({ limit: 5, page: pageParam }),
+ initialPageParam: 1,
+ getNextPageParam: (lastPage) => {
+ if (!lastPage?.meta.hasNextPage) return undefined;
+ const _page = lastPage?.meta?.currentPage ?? 1;
+ return _page + 1;
+ },
+ enabled: feedType === "series",
});
+ const activeFeedQuery =
+ feedType === "articles" ? articleFeedQuery : seriesFeedQuery;
+ const isLoading =
+ feedType === "articles"
+ ? articleFeedQuery.isFetching
+ : seriesFeedQuery.isFetching;
+
return (
-
- {Boolean(feedInfiniteQuery.isFetching) && (
- <>
-
-
-
-
-
-
- >
- )}
+ <>
+
+ setFeedType(value as "articles" | "series")}
+ className="w-full"
+ >
+
+ Articles
+ Series
+
+
+
- {feedInfiniteQuery.data?.pages
- .flatMap((page) => page?.nodes)
- .map((article) => (
-
+ {isLoading && (
+ <>
+
+
+
+
+ >
+ )}
+
+ {feedType === "articles" &&
+ articleFeedQuery.data?.pages
+ .flatMap((page) => page?.nodes)
+ .map((article) => (
+
+ ))}
+
+ {feedType === "series" &&
+ seriesFeedQuery.data?.pages
+ .flatMap((page) => page?.nodes)
+ .map((series) => (
+
+ ))}
+
+
+ {activeFeedQuery.isFetchingNextPage && (
+
+
+
+ )}
+
{
+ console.log(`fetching next page for ${feedType}`);
+ await activeFeedQuery.fetchNextPage();
}}
- publishedAt={article?.created_at.toDateString() ?? ""}
- readingTime={readingTime(article?.body ?? "")}
- likes={0}
- comments={0}
/>
- ))}
-
-
- {
- console.log("fetching next page");
- await feedInfiniteQuery.fetchNextPage();
- // alert("Fetching next page");
- }}
- />
+
-
+ >
);
};
diff --git a/src/app/dashboard/bookmarks/page.tsx b/src/app/(home)/_components/bookmarks/page.tsx
similarity index 100%
rename from src/app/dashboard/bookmarks/page.tsx
rename to src/app/(home)/_components/bookmarks/page.tsx
diff --git a/src/app/dashboard/_components/DashboardSidebar.tsx b/src/app/dashboard/_components/DashboardSidebar.tsx
index 335b271..2258895 100644
--- a/src/app/dashboard/_components/DashboardSidebar.tsx
+++ b/src/app/dashboard/_components/DashboardSidebar.tsx
@@ -31,6 +31,11 @@ const DashboardSidebar = () => {
url: "",
icon: Home,
},
+ {
+ title: _t("Series"),
+ url: "/series",
+ icon: Home,
+ },
{
title: _t("Notifications"),
url: "/notifications",
diff --git a/src/app/dashboard/series/[id]/page.tsx b/src/app/dashboard/series/[id]/page.tsx
new file mode 100644
index 0000000..7c59e49
--- /dev/null
+++ b/src/app/dashboard/series/[id]/page.tsx
@@ -0,0 +1,359 @@
+"use client";
+
+import * as seriesActions from "@/backend/services/series.action";
+import { SortableArticleItem } from "@/components/series/SortableArticleItem";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ closestCenter,
+ DndContext,
+ DragEndEvent,
+ KeyboardSensor,
+ PointerSensor,
+ useSensor,
+ useSensors,
+} from "@dnd-kit/core";
+import {
+ arrayMove,
+ SortableContext,
+ sortableKeyboardCoordinates,
+ verticalListSortingStrategy,
+} from "@dnd-kit/sortable";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { ArrowLeft, Loader, Plus, Save } from "lucide-react";
+import Link from "next/link";
+import { useParams, useRouter } from "next/navigation";
+import { useEffect, useState } from "react";
+
+interface Article {
+ id: string;
+ title: string;
+ slug: string;
+}
+
+interface SeriesItem {
+ id: string;
+ type: string;
+ title: string;
+ article_id?: string;
+ index: number;
+ article?: Article;
+}
+
+interface Series {
+ id: string;
+ title: string;
+ description?: string;
+ handle: string;
+ cover_image?: any;
+ owner_id: string;
+ items: SeriesItem[];
+}
+
+const SeriesEditPage = () => {
+ const params = useParams();
+ const router = useRouter();
+ const queryClient = useQueryClient();
+ const seriesId = params.id as string;
+ const isNewSeries = seriesId === "new";
+
+ // Setup sensors for drag and drop
+ const sensors = useSensors(
+ useSensor(PointerSensor),
+ useSensor(KeyboardSensor, {
+ coordinateGetter: sortableKeyboardCoordinates,
+ })
+ );
+
+ // Local state for form
+ const [title, setTitle] = useState("");
+ const [description, setDescription] = useState("");
+ const [seriesItems, setSeriesItems] = useState([]);
+ const [isSaving, setIsSaving] = useState(false);
+
+ // Fetch series data using server actions
+ const {
+ data: seriesData,
+ isLoading: isSeriesLoading,
+ error: seriesError,
+ } = useQuery({
+ queryKey: ["series", seriesId],
+ queryFn: () => seriesActions.getSeriesById(seriesId),
+ enabled: !isNewSeries && Boolean(seriesId),
+ });
+
+ // Fetch available articles using server actions
+ const { data: availableArticles = [], isLoading: isArticlesLoading } =
+ useQuery({
+ queryKey: ["articles", "own"],
+ queryFn: () => seriesActions.getUserArticles(),
+ });
+
+ // Initialize form with series data when available
+ useEffect(() => {
+ if (seriesData) {
+ setTitle(seriesData.title || "");
+ setDescription(seriesData.description || "");
+ setSeriesItems(seriesData.items || []);
+ }
+ }, [seriesData]);
+
+ // Handle drag end event
+ const handleDragEnd = (event: DragEndEvent) => {
+ const { active, over } = event;
+
+ if (over && active.id !== over.id) {
+ setSeriesItems((items) => {
+ const oldIndex = items.findIndex((item) => item.id === active.id);
+ const newIndex = items.findIndex((item) => item.id === over.id);
+
+ return arrayMove(items, oldIndex, newIndex).map((item, index) => ({
+ ...item,
+ index: index,
+ }));
+ });
+ }
+ };
+
+ // Add article to series
+ const addArticleToSeries = (article: Article) => {
+ // Check if article already exists in series
+ if (seriesItems.some((item) => item.article_id === article.id)) {
+ console.log("This article is already part of the series.");
+ return;
+ }
+
+ const newItem: SeriesItem = {
+ id: `temp-${Date.now()}`,
+ type: "article",
+ title: article.title,
+ article_id: article.id,
+ index: seriesItems.length,
+ article,
+ };
+
+ setSeriesItems((prev) => [...prev, newItem]);
+ };
+
+ // Remove article from series
+ const removeArticleFromSeries = (itemId: string) => {
+ setSeriesItems((prev) =>
+ prev
+ .filter((item) => item.id !== itemId)
+ .map((item, index) => ({
+ ...item,
+ index,
+ }))
+ );
+ };
+
+ // Handle form submission
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+
+ if (!title.trim()) {
+ console.log("Please provide a title for the series");
+ return;
+ }
+
+ setIsSaving(true);
+
+ try {
+ const formData = new FormData();
+ formData.append("title", title);
+ formData.append("description", description || "");
+
+ // Add series items data
+ formData.append("items", JSON.stringify(seriesItems));
+
+ if (isNewSeries) {
+ const result = await seriesActions.createSeries(formData);
+ if (result?.id) {
+ // Navigate to the edit page for the newly created series
+ router.push(`/dashboard/series/${result.id}`);
+ }
+ } else {
+ await seriesActions.updateSeries(seriesId, formData);
+ // Invalidate queries to refresh data
+ queryClient.invalidateQueries({ queryKey: ["series"] });
+ queryClient.invalidateQueries({ queryKey: ["series", seriesId] });
+ }
+
+ console.log(
+ `Series ${isNewSeries ? "created" : "updated"} successfully!`
+ );
+ } catch (error) {
+ console.error(
+ `Error ${isNewSeries ? "creating" : "updating"} series:`,
+ error
+ );
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ if ((isSeriesLoading || isArticlesLoading) && !isNewSeries) {
+ return (
+
+
+
+ );
+ }
+
+ if (seriesError && !isNewSeries) {
+ return (
+
+ {(seriesError as Error).message || "Error loading series data"}
+
+ );
+ }
+
+ return (
+
+
+
+
+ {isNewSeries ? "Create New Series" : "Edit Series"}
+
+
+
+
+
+
+
+
+ Your Articles
+
+ {!availableArticles || availableArticles.length === 0 ? (
+
+ You don't have any articles yet.
+
+ ) : (
+
+ {availableArticles.map((article) => (
+
+
+
+
+ ))}
+
+ )}
+
+
+
+
+ );
+};
+
+export default SeriesEditPage;
diff --git a/src/app/dashboard/series/new/page.tsx b/src/app/dashboard/series/new/page.tsx
new file mode 100644
index 0000000..1de3d42
--- /dev/null
+++ b/src/app/dashboard/series/new/page.tsx
@@ -0,0 +1,389 @@
+"use client";
+
+import * as seriesActions from "@/backend/services/series.action";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { useDebounce } from "@/hooks/use-debounce";
+import {
+ closestCenter,
+ DndContext,
+ DragEndEvent,
+ KeyboardSensor,
+ PointerSensor,
+ useSensor,
+ useSensors,
+} from "@dnd-kit/core";
+import {
+ arrayMove,
+ SortableContext,
+ sortableKeyboardCoordinates,
+ useSortable,
+ verticalListSortingStrategy,
+} from "@dnd-kit/sortable";
+import { CSS } from "@dnd-kit/utilities";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import {
+ ArrowLeft,
+ GripVertical,
+ Loader,
+ Plus,
+ Save,
+ Search,
+ Trash2,
+} from "lucide-react";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { useState } from "react";
+
+// SortableArticleItem component for drag-and-drop functionality
+const SortableArticleItem = ({ id, title, onRemove }) => {
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ transform,
+ transition,
+ isDragging,
+ } = useSortable({ id });
+
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ opacity: isDragging ? 0.5 : 1,
+ };
+
+ return (
+
+
+ {title}
+
+
+ );
+};
+
+const NewSeriesPage = () => {
+ const router = useRouter();
+ const queryClient = useQueryClient();
+
+ // State for form
+ const [title, setTitle] = useState("");
+ const [description, setDescription] = useState("");
+ const [searchTerm, setSearchTerm] = useState("");
+ const [selectedArticles, setSelectedArticles] = useState([]);
+ const [isSaving, setIsSaving] = useState(false);
+
+ // Debounced search term for API calls
+ const debouncedSearchTerm = useDebounce(searchTerm, 500);
+
+ // Setup sensors for drag and drop
+ const sensors = useSensors(
+ useSensor(PointerSensor),
+ useSensor(KeyboardSensor, {
+ coordinateGetter: sortableKeyboardCoordinates,
+ })
+ );
+
+ // Fetch user's articles
+ const {
+ data: userArticles = [],
+ isLoading: isArticlesLoading,
+ refetch: refetchArticles,
+ } = useQuery({
+ queryKey: ["user-articles"],
+ queryFn: () => seriesActions.getUserArticles(),
+ });
+
+ // Filtered articles based on search term
+ const filteredArticles = userArticles.filter((article) =>
+ article.title.toLowerCase().includes(searchTerm.toLowerCase())
+ );
+
+ // Create series mutation
+ const createSeriesMutation = useMutation({
+ mutationFn: (formData) => seriesActions.createSeries(formData),
+ onSuccess: (data) => {
+ queryClient.invalidateQueries({ queryKey: ["series"] });
+ if (data && data.id) {
+ router.push(`/dashboard/series/${data.id}`);
+ } else {
+ // If there's no ID, just redirect to the series list
+ console.warn(
+ "Created series returned no ID, redirecting to series list"
+ );
+ router.push(`/dashboard/series`);
+ }
+ },
+ onError: (error) => {
+ console.error("Error creating series:", error);
+ },
+ });
+
+ // Handle drag end event
+ const handleDragEnd = (event: DragEndEvent) => {
+ const { active, over } = event;
+
+ if (over && active.id !== over.id) {
+ setSelectedArticles((items) => {
+ const oldIndex = items.findIndex((item) => item.id === active.id);
+ const newIndex = items.findIndex((item) => item.id === over.id);
+
+ return arrayMove(items, oldIndex, newIndex);
+ });
+ }
+ };
+
+ // Add article to series
+ const addArticleToSeries = (article) => {
+ // Check if article already exists in series
+ if (selectedArticles.some((item) => item.id === article.id)) {
+ return;
+ }
+
+ setSelectedArticles((prev) => [
+ ...prev,
+ {
+ id: article.id,
+ title: article.title,
+ article_id: article.id,
+ type: "article",
+ },
+ ]);
+ };
+
+ // Remove article from series
+ const removeArticleFromSeries = (articleId) => {
+ setSelectedArticles((prev) =>
+ prev.filter((article) => article.id !== articleId)
+ );
+ };
+
+ // Handle form submission
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+
+ if (!title.trim()) {
+ return;
+ }
+
+ setIsSaving(true);
+
+ try {
+ const formData = new FormData();
+ formData.append("title", title);
+ formData.append("description", description || "");
+
+ // Add selected articles data with index
+ formData.append(
+ "items",
+ JSON.stringify(
+ selectedArticles.map((article, index) => ({
+ ...article,
+ index,
+ }))
+ )
+ );
+
+ createSeriesMutation.mutate(formData);
+ } catch (error) {
+ console.error("Error preparing form data:", error);
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ return (
+
+
+
+
+ Create New Series
+
+
+
+
+
+
+
+
+
+
+ Your Articles
+
+
+
+
+ setSearchTerm(e.target.value)}
+ className="pl-8 bg-background border-border"
+ />
+
+
+
+ {isArticlesLoading ? (
+
+
+
+ ) : filteredArticles.length === 0 ? (
+
+ {searchTerm
+ ? "No articles matching your search."
+ : "You don't have any articles yet."}
+
+ ) : (
+
+ {filteredArticles.map((article) => (
+
a.id === article.id)
+ ? "border-primary"
+ : "border-border"
+ }`}
+ >
+
+
+
+
+
+ ))}
+
+ )}
+
+
+
+
+ );
+};
+
+export default NewSeriesPage;
diff --git a/src/app/dashboard/series/page.tsx b/src/app/dashboard/series/page.tsx
new file mode 100644
index 0000000..c8e0543
--- /dev/null
+++ b/src/app/dashboard/series/page.tsx
@@ -0,0 +1,126 @@
+"use client";
+
+import * as seriesActions from "@/backend/services/series.action";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { Edit, Loader, PlusCircle, Trash2 } from "lucide-react";
+import Link from "next/link";
+
+const SeriesPage = () => {
+ const queryClient = useQueryClient();
+
+ // Use TanStack Query to fetch series data with server actions
+ const {
+ data: series,
+ isLoading,
+ error,
+ } = useQuery({
+ queryKey: ["series"],
+ queryFn: () => seriesActions.getMySeries(),
+ });
+
+ // Use mutation for delete operation
+ const deleteMutation = useMutation({
+ mutationFn: (seriesId: string) => seriesActions.deleteSeries(seriesId),
+ onSuccess: () => {
+ // Invalidate series query to refetch updated list
+ queryClient.invalidateQueries({ queryKey: ["series"] });
+ console.log("Series deleted successfully");
+ },
+ onError: (error) => {
+ console.error("Error deleting series:", error);
+ console.log("Failed to delete series. Please try again.");
+ },
+ });
+
+ const handleDeleteSeries = (id: string) => {
+ if (!window.confirm("Are you sure you want to delete this series?")) {
+ return;
+ }
+ deleteMutation.mutate(id);
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ {(error as Error).message}
+
+ );
+ }
+
+ return (
+
+
+
Your Series
+
+
+
+
+
+ {!series || series.meta.totalCount === 0 ? (
+
+
+ You don't have any series yet.
+
+
+
+
+
+ ) : (
+
+ {series.nodes.map((item) => (
+
+ {item.cover_image && (
+
+ {/*

*/}
+
+ )}
+
+ {item.title}
+ {/*
+ {item.items ? `${item.items.length} articles` : "0 articles"}
+ */}
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+ );
+};
+
+export default SeriesPage;
diff --git a/src/app/dashboard/sessions/page.tsx b/src/app/dashboard/sessions/page.tsx
index 9ab35ba..b1603e8 100644
--- a/src/app/dashboard/sessions/page.tsx
+++ b/src/app/dashboard/sessions/page.tsx
@@ -15,6 +15,7 @@ import {
Loader,
LogOut,
} from "lucide-react";
+import { formatDistance } from "date-fns";
const SessionsPage = () => {
const authSession = useSession();
@@ -44,52 +45,56 @@ const SessionsPage = () => {
)}
{sessionQuery.data?.map((session) => (
-
-
-
-
- {session.device}
-
-
-
-
- Last active {formattedTime(session.last_action_at!)}
-
-
-
-
IP: {session.ip}
-
- {authSession?.session?.id == session.id && (
-
- Current Session
+
+
+
+
+
+
{session.device}
+ {authSession?.session?.id == session.id ? (
+
This device
+ ) : (
+
+
+
+ Last active{" "}
+ {formatDistance(
+ new Date(session.last_action_at!),
+ new Date(),
+ { addSuffix: true }
+ )}
+
)}
-
+
IP: {session.ip}
+
+
+
))}
diff --git a/src/app/series/[handle]/page.tsx b/src/app/series/[handle]/page.tsx
new file mode 100644
index 0000000..c3b315c
--- /dev/null
+++ b/src/app/series/[handle]/page.tsx
@@ -0,0 +1,148 @@
+import * as seriesActions from "@/backend/services/series.action";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { Card } from "@/components/ui/card";
+import getFileUrl from "@/utils/getFileUrl";
+import { Book, BookOpen, Calendar, ChevronRight } from "lucide-react";
+import Image from "next/image";
+import Link from "next/link";
+import { notFound } from "next/navigation";
+
+interface SeriesDetailPageProps {
+ params: {
+ handle: string;
+ };
+}
+
+async function SeriesDetailPage({ params }: SeriesDetailPageProps) {
+ const { handle } = params;
+ const seriesData = await seriesActions.getSeriesDetailByHandle(handle);
+
+ if (!seriesData || !seriesData.series) {
+ notFound();
+ }
+
+ const { series, serieItems } = seriesData;
+
+ return (
+
+
+
+
{series.title}
+
+ {series.description && (
+
{series.description}
+ )}
+
+
+
+
+
+ {series.owner?.name?.[0]}
+
+
+
+
+ {series.owner?.name}
+
+
+
+ {new Date(series.created_at).toLocaleDateString()}
+
+
+
+
+
+
+
+ Articles in this series
+
+ {serieItems.length === 0 ? (
+
+ This series doesn't have any articles yet.
+
+ ) : (
+
+ {serieItems.map((item, index) => (
+
+
+
+ {index + 1}
+
+
+ {item.type === "TITLE" ? (
+
{item.title}
+ ) : (
+
+ {item.article?.title}
+
+
+ )}
+
+
+
+ ))}
+
+ )}
+
+
+
+
+
+ {series.cover_image ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+
About this series
+
+
+ Articles
+
+ {
+ serieItems.filter((item) => item.type === "article")
+ .length
+ }
+
+
+
+ Created
+
+ {new Date(series.created_at).toLocaleDateString()}
+
+
+
+ Updated
+
+ {new Date(series.updated_at).toLocaleDateString()}
+
+
+
+
+
+
+
+
+ );
+}
+
+export default SeriesDetailPage;
diff --git a/src/app/series/page.tsx b/src/app/series/page.tsx
index 1fcbe30..435fc68 100644
--- a/src/app/series/page.tsx
+++ b/src/app/series/page.tsx
@@ -1,17 +1,80 @@
+import { getMySeries } from "@/backend/services/series.action";
+import { Button } from "@/components/ui/button";
import {
- getSeriesDetailByHandle,
- seriesFeed,
-} from "@/backend/services/series.action";
-import React from "react";
+ Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { BookIcon, PlusIcon } from "lucide-react";
+import Link from "next/link";
-const page = async () => {
- const series = await getSeriesDetailByHandle("js");
+const SeriesPage = async () => {
+ const series = await getMySeries();
return (
-
-
{JSON.stringify(series, null, 2)}
+
+
+
+
Series
+
+ Collections of related articles organized in a sequence
+
+
+
+
+
+ {series && series.nodes.length > 0 ? (
+
+ {series.nodes.map((item) => (
+
+
+ {item.title}
+
+ {/*
+
+
+ {item.article_count || 0} articles
+
+ */}
+
+
+
+
+
+ ))}
+
+ ) : (
+
+
+
+
+
No series found
+
+ You haven't created any series yet. Series help you organize
+ multiple articles in a sequence.
+
+
+
+ )}
);
};
-export default page;
+export default SeriesPage;
diff --git a/src/backend/services/article.actions.ts b/src/backend/services/article.actions.ts
index f543eb1..6f24af2 100644
--- a/src/backend/services/article.actions.ts
+++ b/src/backend/services/article.actions.ts
@@ -31,7 +31,17 @@ export async function createMyArticle(
const input =
await ArticleRepositoryInput.createMyArticleInput.parseAsync(_input);
- const handle = await getUniqueArticleHandle(input.title);
+ // Default to "untitled" if title is empty
+ const titleToUse = input.title?.trim() || "Untitled Article";
+
+ // Generate a unique handle based on the title
+ const handle = await getUniqueArticleHandle(titleToUse);
+
+ if (!handle) {
+ throw new RepositoryException(
+ "Failed to generate a unique handle for the article"
+ );
+ }
const article = await persistenceRepository.article.insert([
{
@@ -47,7 +57,9 @@ export async function createMyArticle(
]);
return article?.rows?.[0];
} catch (error) {
+ console.error("Article creation error:", error);
handleRepositoryException(error);
+ return null;
}
}
diff --git a/src/components/Editor/ArticleEditor.tsx b/src/components/Editor/ArticleEditor.tsx
index 6b707d5..dcc42e5 100644
--- a/src/components/Editor/ArticleEditor.tsx
+++ b/src/components/Editor/ArticleEditor.tsx
@@ -13,7 +13,7 @@ import {
HeadingIcon,
ImageIcon,
} from "@radix-ui/react-icons";
-import React, { useRef, useState, useCallback } from "react";
+import React, { useCallback, useRef, useState } from "react";
import { ArticleRepositoryInput } from "@/backend/services/inputs/article.input";
import { useAutosizeTextArea } from "@/hooks/use-auto-resize-textarea";
@@ -71,8 +71,23 @@ const ArticleEditor: React.FC
= ({ article, uuid }) => {
mutationFn: (
input: z.infer
) => articleActions.createMyArticle(input),
- onSuccess: (res) => router.push(`/dashboard/articles/${res?.id}`),
- onError: (err) => alert(err.message),
+ onSuccess: (res) => {
+ if (res && res.id) {
+ router.push(`/dashboard/articles/${res.id}`);
+ } else {
+ console.error("Article created but no ID returned", res);
+ // Fallback to dashboard if ID is missing
+ router.push("/dashboard/articles");
+ }
+ },
+ onError: (err) => {
+ console.error("Error creating article:", err);
+ alert(
+ err instanceof Error
+ ? err.message
+ : "Failed to create article. Please try again."
+ );
+ },
});
const handleDebouncedSaveTitle = useCallback(
diff --git a/src/components/SeriesCard.tsx b/src/components/SeriesCard.tsx
new file mode 100644
index 0000000..b9f256e
--- /dev/null
+++ b/src/components/SeriesCard.tsx
@@ -0,0 +1,106 @@
+import { cn } from "@/lib/utils";
+import { Book } from "lucide-react";
+import Image from "next/image";
+import Link from "next/link";
+import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
+import { Button } from "./ui/button";
+import { Card } from "./ui/card";
+
+interface SeriesCardProps {
+ id: string;
+ handle: string;
+ title: string;
+ description?: string;
+ coverImage?: string;
+ creator: {
+ id: string;
+ name: string;
+ avatar?: string;
+ username: string;
+ };
+ articleCount: number;
+ className?: string;
+}
+
+const SeriesCard = ({
+ id,
+ handle,
+ title,
+ description,
+ coverImage,
+ creator,
+ articleCount,
+ className,
+}: SeriesCardProps) => {
+ const seriesUrl = `/series/${handle}`;
+ const creatorUrl = `/@${creator.username}`;
+
+ return (
+
+
+
+ {coverImage ? (
+
+
+
+ ) : (
+
+
+
+
+
+ )}
+
+
+
+
+
{title}
+
+
+ {description && (
+
+ {description}
+
+ )}
+
+
+
+
+
+
+ {articleCount} {articleCount === 1 ? "article" : "articles"}
+
+
+
+
+
+
+
+
+
+ {creator.name.charAt(0).toUpperCase()}
+
+
+
{creator.name}
+
+
+
+
+
+
+
+
+ );
+};
+
+export default SeriesCard;
diff --git a/src/components/series/SortableArticleItem.tsx b/src/components/series/SortableArticleItem.tsx
new file mode 100644
index 0000000..d9b1527
--- /dev/null
+++ b/src/components/series/SortableArticleItem.tsx
@@ -0,0 +1,58 @@
+import { Button } from "@/components/ui/button";
+import { useSortable } from "@dnd-kit/sortable";
+import { CSS } from "@dnd-kit/utilities";
+import { GripVertical, X } from "lucide-react";
+
+interface SortableArticleItemProps {
+ id: string;
+ title: string;
+ onRemove: () => void;
+}
+
+export function SortableArticleItem({
+ id,
+ title,
+ onRemove,
+}: SortableArticleItemProps) {
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ transform,
+ transition,
+ isDragging,
+ } = useSortable({ id });
+
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ opacity: isDragging ? 0.5 : 1,
+ };
+
+ return (
+
+
+ {title}
+
+
+ );
+}
diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx
index 485ac82..273335d 100644
--- a/src/components/ui/sidebar.tsx
+++ b/src/components/ui/sidebar.tsx
@@ -1,12 +1,10 @@
"use client";
-import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { VariantProps, cva } from "class-variance-authority";
import { PanelLeftIcon } from "lucide-react";
+import * as React from "react";
-import { useIsMobile } from "@/hooks/use-mobile";
-import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
@@ -24,6 +22,8 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
+import { useIsMobile } from "@/hooks/use-mobile";
+import { cn } from "@/lib/utils";
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
diff --git a/src/hooks/use-debounce.ts b/src/hooks/use-debounce.ts
new file mode 100644
index 0000000..145fab5
--- /dev/null
+++ b/src/hooks/use-debounce.ts
@@ -0,0 +1,17 @@
+import { useEffect, useState } from "react";
+
+export function useDebounce(value: T, delay: number): T {
+ const [debouncedValue, setDebouncedValue] = useState(value);
+
+ useEffect(() => {
+ const handler = setTimeout(() => {
+ setDebouncedValue(value);
+ }, delay);
+
+ return () => {
+ clearTimeout(handler);
+ };
+ }, [value, delay]);
+
+ return debouncedValue;
+}