Skip to content
Open
Show file tree
Hide file tree
Changes from all 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>
);
}
119 changes: 119 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,119 @@
"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" ? (
<Image
src={newsletter.coverImage}
alt={newsletter.title}
fill
className="object-contain"
unoptimized
/>
) : (
<Image
src={newsletter.coverImage}
alt={newsletter.title}
fill
className="object-contain"
/>
)}
</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,67 @@
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" ? (
<Image
src={newsletter.coverImage}
alt={newsletter.title}
fill
className="object-contain transition-transform duration-300 hover:scale-105"
unoptimized
/>
) : (
<Image
src={newsletter.coverImage}
alt={newsletter.title}
fill
className="object-contain 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>
);
}

Loading