diff --git a/apps/web/package.json b/apps/web/package.json index 7848f7c..987ee07 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -15,7 +15,7 @@ "@radix-ui/react-checkbox": "^1.1.2", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-radio-group": "^1.2.1", - "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-slot": "^1.2.3", "@tanstack/react-query": "^5.90.2", "@trpc/client": "^11.6.0", "@trpc/react-query": "^11.6.0", @@ -26,7 +26,9 @@ "clsx": "^2.1.1", "framer-motion": "^11.15.0", "geist": "^1.5.1", + "gray-matter": "^4.0.3", "lucide-react": "^0.456.0", + "marked": "^17.0.0", "next": "15.5.3", "next-auth": "^4.24.11", "next-themes": "^0.4.3", diff --git a/apps/web/src/app/(main)/dashboard/newsletters/[slug]/page.tsx b/apps/web/src/app/(main)/dashboard/newsletters/[slug]/page.tsx new file mode 100644 index 0000000..08b4e79 --- /dev/null +++ b/apps/web/src/app/(main)/dashboard/newsletters/[slug]/page.tsx @@ -0,0 +1,149 @@ +"use client"; + +import "@/styles/newsletter.css"; + +import { useEffect, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { CalendarIcon, ClockIcon, ArrowLeftIcon } from "@heroicons/react/24/outline"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useSubscription } from "@/hooks/useSubscription"; + +interface NewsletterData { + title: string; + date: string; + readTime: string; + content: string; +} + +function NewsletterSkeleton() { + return ( +
+
+ + +
+ + +
+
+
+ + + + + + +
+
+ ); +} + +export default function NewsletterPage() { + const params = useParams(); + const router = useRouter(); + const slug = params.slug as string; + const [newsletter, setNewsletter] = useState(null); + const [loading, setLoading] = useState(true); + const { isLoading: subscriptionLoading } = useSubscription(); + + useEffect(() => { + // Fetch for all users (testing mode) + if (subscriptionLoading) return; + + fetch(`/api/newsletters/${slug}`) + .then((res) => res.json()) + .then((data) => { + if (data.error) { + setNewsletter(null); + } else { + setNewsletter(data); + } + setLoading(false); + }) + .catch(() => { + setNewsletter(null); + setLoading(false); + }); + }, [slug, subscriptionLoading]); + + if (subscriptionLoading) { + return ( +
+
+ + +
+
+ ); + } + + if (loading) { + return ( +
+
+ + +
+
+ ); + } + + if (!newsletter) { + return ( +
+
+

Newsletter not found

+ +
+
+ ); + } + + return ( +
+
+ + +
+
+

+ {newsletter.title} +

+
+ + + + {new Date(newsletter.date).toLocaleDateString("en-US", { + month: "long", + day: "numeric", + year: "numeric", + })} + + + + + {newsletter.readTime} + +
+
+ +
+
+
+
+ ); +} diff --git a/apps/web/src/app/(main)/dashboard/newsletters/page.tsx b/apps/web/src/app/(main)/dashboard/newsletters/page.tsx new file mode 100644 index 0000000..536389a --- /dev/null +++ b/apps/web/src/app/(main)/dashboard/newsletters/page.tsx @@ -0,0 +1,105 @@ +"use client"; + +import "@/styles/newsletter.css"; + +import { useEffect, useState, useMemo } from "react"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useSubscription } from "@/hooks/useSubscription"; +import { Newsletter } from "@/components/newsletters/NewsletterCard"; +import { NewsletterSkeleton } from "@/components/newsletters/NewsletterSkeleton"; +import { PremiumUpgradePrompt } from "@/components/newsletters/PremiumUpgradePrompt"; +import { NewsletterFilters, TimeFilter } from "@/components/newsletters/NewsletterFilters"; +import { NewsletterPagination } from "@/components/newsletters/NewsletterPagination"; +import { NewsletterList } from "@/components/newsletters/NewsletterList"; +import { useNewsletterFilters } from "@/hooks/useNewsletterFilters"; + +export default function NewslettersPage() { + const [newsletters, setNewsletters] = useState([]); + const [loading, setLoading] = useState(true); + const [searchQuery, setSearchQuery] = useState(""); + const [timeFilter, setTimeFilter] = useState("all"); + const [currentPage, setCurrentPage] = useState(1); + const { isPaidUser, isLoading: subscriptionLoading } = useSubscription(); + + const itemsPerPage = 5; + + useEffect(() => { + // Fetch newsletters for all users (testing mode) + if (subscriptionLoading) return; + + fetch("/api/newsletters") + .then((res) => res.json()) + .then((data) => { + setNewsletters(data); + setLoading(false); + }) + .catch(() => setLoading(false)); + }, [subscriptionLoading]); + + const filteredNewsletters = useNewsletterFilters(newsletters, searchQuery, timeFilter); + + const totalPages = Math.ceil(filteredNewsletters.length / itemsPerPage); + const paginatedNewsletters = useMemo(() => { + const startIndex = (currentPage - 1) * itemsPerPage; + return filteredNewsletters.slice(startIndex, startIndex + itemsPerPage); + }, [filteredNewsletters, currentPage]); + + // Reset to page 1 when filters change + useEffect(() => { + setCurrentPage(1); + }, [searchQuery, timeFilter]); + + if (subscriptionLoading) { + return ( +
+
+ + + +
+
+ ); + } + + if (!isPaidUser) { + return ; + } + + return ( +
+
+
+

+ Newsletters +

+

+ Stay updated with our latest news and insights +

+
+ +
+ + +
+ +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/app/api/newsletters/[slug]/route.ts b/apps/web/src/app/api/newsletters/[slug]/route.ts new file mode 100644 index 0000000..3148322 --- /dev/null +++ b/apps/web/src/app/api/newsletters/[slug]/route.ts @@ -0,0 +1,57 @@ +import { NextResponse } from "next/server"; +import fs from "fs"; +import path from "path"; +import matter from "gray-matter"; +import { marked } from "marked"; + +// Configure marked for rich markdown support +marked.setOptions({ + gfm: true, // GitHub Flavored Markdown: tables, task lists, etc. + breaks: true, // Line breaks +}); + +// Cache individual newsletters +const newsletterCache = new Map(); +const CACHE_DURATION = 60_000; // 1 minute + +export async function GET( + _request: Request, + { params }: { params: Promise<{ slug: string }> } +) { + const { slug } = await params; + const now = Date.now(); + const cached = newsletterCache.get(slug); + + if (cached && now - cached.time < CACHE_DURATION) { + return NextResponse.json(cached.data); + } + + const newslettersDir = path.join(process.cwd(), "src/content/newsletters"); + const filePath = path.join(newslettersDir, `${slug}.md`); + + try { + if (!fs.existsSync(filePath)) { + return NextResponse.json({ error: "Newsletter not found" }, { status: 404 }); + } + + const fileContent = fs.readFileSync(filePath, "utf8"); + const { data, content } = matter(fileContent); + + // Render markdown (supports headings, links, lists, code blocks, images, tables, etc.) + const htmlContent = marked.parse(content); + + const result = { + title: data.title || "Untitled", + date: data.date || new Date().toISOString(), + readTime: data.readTime || "5 min read", + content: htmlContent, + }; + + newsletterCache.set(slug, { data: result, time: now }); + + return NextResponse.json(result); + } catch (error) { + console.error("Error reading newsletter:", error); + return NextResponse.json({ error: "Newsletter not found" }, { status: 404 }); + } +} diff --git a/apps/web/src/app/api/newsletters/route.ts b/apps/web/src/app/api/newsletters/route.ts new file mode 100644 index 0000000..179b842 --- /dev/null +++ b/apps/web/src/app/api/newsletters/route.ts @@ -0,0 +1,55 @@ +import { NextResponse } from "next/server"; +import fs from "fs"; +import path from "path"; +import matter from "gray-matter"; + +// Cache newsletters in memory for faster subsequent loads +let cachedNewsletters: any[] | null = null; +let lastCacheTime = 0; +const CACHE_DURATION = 60000; // 1 minute cache + +export async function GET() { + const now = Date.now(); + + // Return cached data if available and fresh + if (cachedNewsletters && now - lastCacheTime < CACHE_DURATION) { + return NextResponse.json(cachedNewsletters); + } + + const newslettersDir = path.join(process.cwd(), "src/content/newsletters"); + + try { + if (!fs.existsSync(newslettersDir)) { + fs.mkdirSync(newslettersDir, { recursive: true }); + return NextResponse.json([]); + } + + const files = fs.readdirSync(newslettersDir); + + const newsletters = files + .filter((file) => file.endsWith(".md")) + .map((file) => { + const filePath = path.join(newslettersDir, file); + const fileContent = fs.readFileSync(filePath, "utf8"); + const { data } = matter(fileContent); + + return { + slug: file.replace(".md", ""), + title: data.title || "Untitled", + date: data.date || new Date().toISOString(), + excerpt: data.excerpt || "", + readTime: data.readTime || "5 min read", + }; + }) + .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); + + // Update cache + cachedNewsletters = newsletters; + lastCacheTime = now; + + return NextResponse.json(newsletters); + } catch (error) { + console.error("Error reading newsletters:", error); + return NextResponse.json([]); + } +} diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 6ae57e0..7d204d3 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -60,9 +60,7 @@ --chart-4: 280 65% 60%; --chart-5: 340 75% 55%; } -} -@layer base { * { @apply border-border; } @@ -73,4 +71,5 @@ html { scroll-behavior: smooth; -} \ No newline at end of file +} + diff --git a/apps/web/src/components/dashboard/Sidebar.tsx b/apps/web/src/components/dashboard/Sidebar.tsx index eabb046..a8cf189 100644 --- a/apps/web/src/components/dashboard/Sidebar.tsx +++ b/apps/web/src/components/dashboard/Sidebar.tsx @@ -17,12 +17,14 @@ import { StarIcon, HeartIcon, EnvelopeIcon, + NewspaperIcon, } from "@heroicons/react/24/outline"; import { useShowSidebar } from "@/store/useShowSidebar"; import { signOut } from "next-auth/react"; import { Twitter } from "../icons/icons"; import { ProfilePic } from "./ProfilePic"; import { useFilterStore } from "@/store/useFilterStore"; +import { Badge } from "@/components/ui/badge"; const SIDEBAR_ROUTES = [ { @@ -112,8 +114,6 @@ export default function Sidebar() {
- {/* Find projects entry */} - {SIDEBAR_ROUTES.map((route) => { const activeClass = getSidebarLinkClassName(pathname, route.path); return ( @@ -126,12 +126,35 @@ export default function Sidebar() { ); })} + } collapsed={isCollapsed} /> + + +
+ } + collapsed={isCollapsed} + /> + {!isCollapsed && ( + + PRO + + )} +
+ + +
+

+ {newsletter.title} +

+
+ + + + {new Date(newsletter.date).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + })} + + + + + {newsletter.readTime} + +
+

+ {newsletter.excerpt} +

+
+ + ); +} diff --git a/apps/web/src/components/newsletters/NewsletterFilters.tsx b/apps/web/src/components/newsletters/NewsletterFilters.tsx new file mode 100644 index 0000000..9b6b630 --- /dev/null +++ b/apps/web/src/components/newsletters/NewsletterFilters.tsx @@ -0,0 +1,57 @@ +import { MagnifyingGlassIcon } from "@heroicons/react/24/outline"; +import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group"; + +export type TimeFilter = "all" | "day" | "week" | "month"; + +interface NewsletterFiltersProps { + searchQuery: string; + onSearchChange: (query: string) => void; + timeFilter: TimeFilter; + onTimeFilterChange: (filter: TimeFilter) => void; +} + +export function NewsletterFilters({ + searchQuery, + onSearchChange, + timeFilter, + onTimeFilterChange, +}: NewsletterFiltersProps) { + const filters: { value: TimeFilter; label: string }[] = [ + { value: "all", label: "All Time" }, + { value: "day", label: "Today" }, + { value: "week", label: "This Week" }, + { value: "month", label: "This Month" }, + ]; + + return ( +
+ + + + + onSearchChange(e.target.value)} + /> + + +
+ {filters.map((filter) => ( + + ))} +
+
+ ); +} diff --git a/apps/web/src/components/newsletters/NewsletterList.tsx b/apps/web/src/components/newsletters/NewsletterList.tsx new file mode 100644 index 0000000..1f713f4 --- /dev/null +++ b/apps/web/src/components/newsletters/NewsletterList.tsx @@ -0,0 +1,40 @@ +import { NewsletterCard, Newsletter } from "./NewsletterCard"; +import { NewsletterSkeleton } from "./NewsletterSkeleton"; + +interface NewsletterListProps { + newsletters: Newsletter[]; + loading: boolean; + hasFilters: boolean; +} + +export function NewsletterList({ newsletters, loading, hasFilters }: NewsletterListProps) { + if (loading) { + return ( + <> + + + + + ); + } + + if (newsletters.length === 0) { + return ( +
+

+ {hasFilters + ? "No newsletters found matching your criteria." + : "No newsletters available yet."} +

+
+ ); + } + + return ( + <> + {newsletters.map((newsletter) => ( + + ))} + + ); +} diff --git a/apps/web/src/components/newsletters/NewsletterPagination.tsx b/apps/web/src/components/newsletters/NewsletterPagination.tsx new file mode 100644 index 0000000..8b6071f --- /dev/null +++ b/apps/web/src/components/newsletters/NewsletterPagination.tsx @@ -0,0 +1,89 @@ +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from "@/components/ui/pagination"; + +interface NewsletterPaginationProps { + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; +} + +export function NewsletterPagination({ + currentPage, + totalPages, + onPageChange, +}: NewsletterPaginationProps) { + if (totalPages <= 1) return null; + + const getPageNumbers = () => { + const pages: (number | string)[] = []; + const maxVisible = 5; + + if (totalPages <= maxVisible) { + return Array.from({ length: totalPages }, (_, i) => i + 1); + } + + pages.push(1); + + if (currentPage > 3) { + pages.push("ellipsis-start"); + } + + const start = Math.max(2, currentPage - 1); + const end = Math.min(totalPages - 1, currentPage + 1); + + for (let i = start; i <= end; i++) { + pages.push(i); + } + + if (currentPage < totalPages - 2) { + pages.push("ellipsis-end"); + } + + pages.push(totalPages); + + return pages; + }; + + return ( + + + + onPageChange(Math.max(1, currentPage - 1))} + className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"} + /> + + + {getPageNumbers().map((page, index) => ( + + {typeof page === "number" ? ( + onPageChange(page)} + isActive={currentPage === page} + className="cursor-pointer" + > + {page} + + ) : ( + + )} + + ))} + + + onPageChange(Math.min(totalPages, currentPage + 1))} + className={currentPage === totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"} + /> + + + + ); +} diff --git a/apps/web/src/components/newsletters/NewsletterSkeleton.tsx b/apps/web/src/components/newsletters/NewsletterSkeleton.tsx new file mode 100644 index 0000000..4f4f4b0 --- /dev/null +++ b/apps/web/src/components/newsletters/NewsletterSkeleton.tsx @@ -0,0 +1,15 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +export function NewsletterSkeleton() { + return ( +
+ +
+ + +
+ + +
+ ); +} diff --git a/apps/web/src/components/newsletters/PremiumUpgradePrompt.tsx b/apps/web/src/components/newsletters/PremiumUpgradePrompt.tsx new file mode 100644 index 0000000..b1d6745 --- /dev/null +++ b/apps/web/src/components/newsletters/PremiumUpgradePrompt.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { SparklesIcon, LockClosedIcon } from "@heroicons/react/24/outline"; +import PrimaryButton from "@/components/ui/custom-button"; + +export function PremiumUpgradePrompt() { + const router = useRouter(); + + return ( +
+
+
+
+ +
+
+ +

+ OX Newsletter +

+ +

+ Stay ahead in the open source world. Get curated insights on jobs, funding news, trending projects, upcoming trends, and expert tips. +

+ + router.push("/pricing")} + classname="w-full px-6" + > + + Unlock Premium + +
+
+ ); +} diff --git a/apps/web/src/components/ui/input-group.tsx b/apps/web/src/components/ui/input-group.tsx new file mode 100644 index 0000000..0777399 --- /dev/null +++ b/apps/web/src/components/ui/input-group.tsx @@ -0,0 +1,170 @@ +"use client" + +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" + +function InputGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
textarea]:h-auto", + + // Variants based on alignment. + "has-[>[data-align=inline-start]]:[&>input]:pl-2", + "has-[>[data-align=inline-end]]:[&>input]:pr-2", + "has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3", + "has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3", + + // Focus state. + "has-[[data-slot=input-group-control]:focus-visible]:ring-ring has-[[data-slot=input-group-control]:focus-visible]:ring-1", + + // Error state. + "has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40", + + className + )} + {...props} + /> + ) +} + +const inputGroupAddonVariants = cva( + "text-muted-foreground flex h-auto cursor-text select-none items-center justify-center gap-2 py-1.5 text-sm font-medium group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4", + { + variants: { + align: { + "inline-start": + "order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]", + "inline-end": + "order-last pr-3 has-[>button]:mr-[-0.4rem] has-[>kbd]:mr-[-0.35rem]", + "block-start": + "[.border-b]:pb-3 order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5", + "block-end": + "[.border-t]:pt-3 order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5", + }, + }, + defaultVariants: { + align: "inline-start", + }, + } +) + +function InputGroupAddon({ + className, + align = "inline-start", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
{ + if ((e.target as HTMLElement).closest("button")) { + return + } + e.currentTarget.parentElement?.querySelector("input")?.focus() + }} + {...props} + /> + ) +} + +const inputGroupButtonVariants = cva( + "flex items-center gap-2 text-sm shadow-none", + { + variants: { + size: { + xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5", + sm: "h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5", + "icon-xs": + "size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0", + "icon-sm": "size-8 p-0 has-[>svg]:p-0", + }, + }, + defaultVariants: { + size: "xs", + }, + } +) + +function InputGroupButton({ + className, + type = "button", + variant = "ghost", + size = "xs", + ...props +}: Omit, "size"> & + VariantProps) { + return ( +