From f15242e49317facbef511a07b5693f3630b85caa Mon Sep 17 00:00:00 2001 From: Shoaib Sharif Date: Sun, 6 Apr 2025 02:30:33 -0500 Subject: [PATCH 1/3] Updated session dashboard --- package.json | 1 + src/app/dashboard/sessions/page.tsx | 93 +++++++++++++++-------------- 2 files changed, 50 insertions(+), 44 deletions(-) diff --git a/package.json b/package.json index 480cfae..27d8659 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,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/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}
+ + +
))} From 10d146d81cfbfe2541a61969d25a2bc6891edfb2 Mon Sep 17 00:00:00 2001 From: Shoaib Sharif Date: Mon, 28 Apr 2025 04:41:07 -0500 Subject: [PATCH 2/3] feat: Implement series management features including creation, editing, and deletion --- .gitignore | 1 + package.json | 3 + src/app/(home)/_components/ArticleFeed.tsx | 155 ++++-- .../_components}/bookmarks/page.tsx | 0 .../_components/DashboardSidebar.tsx | 5 + src/app/dashboard/series/[id]/page.tsx | 359 +++++++++++++ src/app/dashboard/series/new/page.tsx | 389 ++++++++++++++ src/app/dashboard/series/page.tsx | 126 +++++ src/app/series/[handle]/page.tsx | 148 ++++++ src/app/series/page.tsx | 81 ++- src/backend/services/article.actions.ts | 21 +- src/backend/services/series.action.ts | 475 +++++++++++++++++- src/components/Editor/ArticleEditor.tsx | 21 +- src/components/SeriesCard.tsx | 106 ++++ src/components/series/SortableArticleItem.tsx | 58 +++ src/components/ui/sidebar.tsx | 6 +- src/hooks/use-debounce.ts | 17 + 17 files changed, 1906 insertions(+), 65 deletions(-) rename src/app/{dashboard => (home)/_components}/bookmarks/page.tsx (100%) create mode 100644 src/app/dashboard/series/[id]/page.tsx create mode 100644 src/app/dashboard/series/new/page.tsx create mode 100644 src/app/dashboard/series/page.tsx create mode 100644 src/app/series/[handle]/page.tsx create mode 100644 src/components/SeriesCard.tsx create mode 100644 src/components/series/SortableArticleItem.tsx create mode 100644 src/hooks/use-debounce.ts 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 27d8659..c8d9eda 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,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", 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 ( +
+
+ + + Back to series + +
+ +

+ {isNewSeries ? "Create New Series" : "Edit Series"} +

+ +
+
+
+
+
+ + setTitle(e.target.value)} + placeholder="Enter series title" + required + className="bg-background border-border" + /> +
+ +
+ +