Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions apps/web/src/app/(main)/dashboard/newsletters/Content.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"use client";

import { useState, useMemo } from "react";
import { Newsletter } from "@/types/newsletter";
import { GeistSans } from "geist/font/sans";
import { getAvailableMonths } from "./utils/newsletter.utils";
import { filterNewsletters } from "./utils/newsletter.filters";
import NewsletterFilters from "./components/NewsletterFilters";
import NewsletterList from "./components/NewsletterList";
import NewsletterEmptyState from "./components/NewsletterEmptyState";

interface NewslettersProps {
newsletters: Newsletter[];
}

export default function Newsletters({ newsletters }: NewslettersProps) {
const [searchQuery, setSearchQuery] = useState("");
const [selectedMonth, setSelectedMonth] = useState<string>("all");

const availableMonths = useMemo(
() => getAvailableMonths(newsletters),
[newsletters]
);

const filteredNewsletters = useMemo(
() => filterNewsletters(newsletters, searchQuery, selectedMonth),
[newsletters, searchQuery, selectedMonth]
);

const handleClearFilters = () => {
setSearchQuery("");
setSelectedMonth("all");
};

const hasActiveFilters =
searchQuery.trim() !== "" || selectedMonth !== "all";

return (
<div className="min-h-screen bg-background font-sans">
<div className="max-w-4xl mx-auto px-4 py-12 sm:px-6 lg:px-8">
<div className="mb-8 text-center">
<h1
className={`text-4xl font-bold text-foreground mb-4 ${GeistSans.className}`}
>
Newsletters
</h1>
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
Stay updated with the latest features, tips, and insights from
opensox.ai
</p>
</div>
<NewsletterFilters
searchQuery={searchQuery}
selectedMonth={selectedMonth}
availableMonths={availableMonths}
resultCount={filteredNewsletters.length}
onSearchChange={setSearchQuery}
onMonthChange={setSelectedMonth}
onClearFilters={handleClearFilters}
/>

{filteredNewsletters.length === 0 ? (
<NewsletterEmptyState
hasActiveFilters={hasActiveFilters}
onClearFilters={handleClearFilters}
/>
) : (
<NewsletterList newsletters={filteredNewsletters} />
)}
</div>
</div>
);
}
117 changes: 117 additions & 0 deletions apps/web/src/app/(main)/dashboard/newsletters/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
"use client";

import { useParams } from "next/navigation";
import Link from "next/link";
import { newsletters } from "../data/newsletters";
import NewsletterContent from "../components/NewsletterContent";
import { Calendar, Clock, ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import Image from "next/image";
import { NewsletterContentItem } from "@/types/newsletter";
import { GeistSans } from "geist/font/sans";
import { formatNewsletterDate } from "../utils/newsletter.utils";

export default function NewsletterPage() {
const params = useParams();
const id = params.id as string;
const newsletter = newsletters.find((n) => n.id === id);

if (!newsletter) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold text-foreground mb-4">
Newsletter not found
</h1>
<Link href="/dashboard/newsletters">
<Button variant="outline">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to newsletters
</Button>
</Link>
</div>
</div>
);
}

const formattedDate = formatNewsletterDate(newsletter.date);

return (
<div className="min-h-screen bg-background font-sans">
<div className="max-w-3xl mx-auto px-4 py-12 sm:px-6 lg:px-8">
{/* back button */}
<Link href="/dashboard/newsletters">
<Button variant="ghost" className="mb-8 -ml-2 hover:bg-secondary">
<ArrowLeft className="h-4 w-4 mr-2" />
All newsletters
</Button>
</Link>

{/* newsletter header */}
<header className="mb-12">
{newsletter.coverImage && (
<div className="relative h-[400px] w-full overflow-hidden rounded-lg mb-8 bg-muted">
{typeof newsletter.coverImage === "string" ? (
<img
src={newsletter.coverImage}
alt={newsletter.title}
className="w-full h-full object-cover"
/>
) : (
<Image
src={newsletter.coverImage}
alt={newsletter.title}
fill
className="object-cover"
/>
)}
</div>
)}

<h1 className={`text-2xl md:text-4xl font-bold text-foreground mb-6 ${GeistSans.className}`}>
{newsletter.title}
</h1>

<div className="flex flex-wrap items-center gap-4 text-sm text-muted-foreground mb-4">
<div className="flex items-center gap-1.5">
<Calendar className="h-4 w-4" />
<span>{formattedDate}</span>
</div>
{newsletter.readTime && (
<div className="flex items-center gap-1.5">
<Clock className="h-4 w-4" />
<span>{newsletter.readTime}</span>
</div>
)}
{newsletter.author && <span>by {newsletter.author}</span>}
</div>

{newsletter.excerpt && (
<p className="text-lg text-muted-foreground leading-relaxed">
{newsletter.excerpt}
</p>
)}
</header>

{/* divider */}
<div className="border-t border-border mb-12" />

{/* newsletter content */}
<div className="prose prose-lg max-w-none font-sans">
<NewsletterContent content={newsletter.content as NewsletterContentItem[]} />
</div>

{/* footer */}
<div className="mt-16 pt-8 border-t border-border">
<Link href="/dashboard/newsletters">
<Button variant="outline" className="w-full sm:w-auto">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to all newsletters
</Button>
</Link>
</div>
</div>
</div>
);
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import Link from "next/link";
import { Calendar, Clock } from "lucide-react";
import { Card } from "@/components/ui/card";
import { Newsletter } from "@/types/newsletter";
import Image from "next/image";
import { GeistSans } from "geist/font/sans";
import { formatNewsletterDate } from "../utils/newsletter.utils";

interface NewsletterCardProps {
newsletter: Newsletter;
}

export default function NewsletterCard({ newsletter }: NewsletterCardProps) {
const formattedDate = formatNewsletterDate(newsletter.date);

return (
<Link href={`/dashboard/newsletters/${newsletter.id}`}>
<Card className="overflow-hidden hover:shadow-lg transition-all duration-300 border-border hover:border-[#693dab] cursor-pointer">
{newsletter.coverImage && (
<div className="relative h-48 w-full overflow-hidden bg-muted">
{typeof newsletter.coverImage === "string" ? (
<img
src={newsletter.coverImage}
alt={newsletter.title}
className="w-full h-full object-cover transition-transform duration-300 hover:scale-105 "
/>
) : (
<Image
src={newsletter.coverImage}
alt={newsletter.title}
fill
className="object-cover transition-transform duration-300 hover:scale-105 hover:opacity-80"
/>
)}
</div>
)}
<div className="p-6 space-y-3">
<h2 className={`text-2xl font-semibold text-foreground hover:text-primary transition-colors ${GeistSans.className}`}>
{newsletter.title}
</h2>

<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1.5">
<Calendar className="h-4 w-4" />
<span>{formattedDate}</span>
</div>
{newsletter.readTime && (
<div className="flex items-center gap-1.5">
<Clock className="h-4 w-4" />
<span>{newsletter.readTime}</span>
</div>
)}
</div>
<p className="text-foreground/80 line-clamp-2 leading-relaxed">
{newsletter.excerpt}
</p>
{newsletter.author && (
<p className="text-sm text-muted-foreground">by {newsletter.author}</p>
)}
</div>
</Card>
</Link>
);
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
"use client";

import { NewsletterContentItem } from "@/types/newsletter";
import Link from "next/link";
import Image from "next/image";

interface NewsletterContentProps {
content: NewsletterContentItem[];
}

export default function NewsletterContent({ content }: NewsletterContentProps) {
return (
<div className="space-y-6 font-sans">
{content.map((item, index) => {
switch (item.type) {
case "paragraph":
return (
<p key={index} className="text-foreground/90 leading-relaxed">
{item.content}
</p>
);

case "heading":
const HeadingTag = `h${item.level}` as keyof JSX.IntrinsicElements;
const headingClasses = {
1: "text-4xl font-bold mb-4 mt-8",
2: "text-3xl font-bold mb-4 mt-8",
3: "text-2xl font-semibold mb-3 mt-6",
};
return (
<HeadingTag
key={index}
className={headingClasses[item.level]}
>
{item.content}
</HeadingTag>
);

case "bold":
return (
<p key={index} className="font-semibold text-foreground mb-2">
{item.content}
</p>
);

case "link":
return (
<div key={index} className="my-4">
<Link
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline font-medium"
>
{item.text}
</Link>
</div>
);

case "image":
return (
<div key={index} className="my-8">
<img
src={item.src}
alt={item.alt || ""}
className="w-full rounded-lg"
/>
</div>
);

case "list":
const isRightAligned = item.align === "left";
return (
<div
key={index}
className={`my-4 ${
isRightAligned ? "flex justify-end" : ""
}`}
>
<ul
className={`space-y-2 ${
isRightAligned
? "list-none text-right"
: "list-disc list-inside"
}`}
>
{item.items.map((listItem, itemIndex) => (
<li
key={itemIndex}
className={`text-foreground/90 ${
isRightAligned
? "flex items-center justify-end gap-2"
: ""
}`}
>
<span>{listItem}</span>
{isRightAligned && (
<span className="text-foreground/60 flex-shrink-0">
</span>
)}
</li>
))}
</ul>
</div>
);

default:
return null;
}
})}
</div>
);
}

Loading