Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
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
4 changes: 4 additions & 0 deletions apps/web/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ const nextConfig = {
protocol: "https",
hostname: "avatars.githubusercontent.com",
},
{
protocol: "https",
hostname: "images.unsplash.com",
},
],
},
};
Expand Down
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +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-select": "^2.2.6",
"@radix-ui/react-slot": "^1.1.0",
"@tanstack/react-query": "^5.90.2",
"@trpc/client": "^11.6.0",
Expand Down
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