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
2 changes: 2 additions & 0 deletions apps/api/src/routers/_app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { userRouter } from "./user.js";
import { projectRouter } from "./projects.js";
import { authRouter } from "./auth.js";
import { z } from "zod";
import { newsletterRouter } from "./newsletter.js";

const testRouter = router({
test: publicProcedure
Expand All @@ -19,6 +20,7 @@ export const appRouter = router({
user: userRouter,
project: projectRouter,
auth: authRouter,
newsletter: newsletterRouter,
});

export type AppRouter = typeof appRouter;
13 changes: 13 additions & 0 deletions apps/api/src/routers/newsletter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { z } from "zod";
import { router, publicProcedure } from "../trpc.js";
import { newsletterService } from "../services/newsletter.service.js";

export const newsletterRouter = router({
list: publicProcedure
.input(z.object({ search: z.string().optional() }).optional())
.query(async ({ input }) => newsletterService.list(input?.search)),

bySlug: publicProcedure
.input(z.object({ slug: z.string() }))
.query(async ({ input }) => newsletterService.bySlug(input.slug)),
});
45 changes: 45 additions & 0 deletions apps/api/src/services/newsletter.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Prisma } from "@prisma/client";
import dbClient from "../prisma.js";

const { prisma } = dbClient;

export const newsletterService = {
list: async (search?: string) => {
const where: Prisma.NewsletterIssueWhereInput | undefined = search
? {
OR: [
{ title: { contains: search, mode: "insensitive" } },
{ summary: { contains: search, mode: "insensitive" } },
{ tags: { hasSome: search.split(" ").filter(Boolean) } },
],
}
: undefined;

return prisma.newsletterIssue.findMany({
...(where && { where }),
orderBy: { publishedAt: "desc" },
select: {
id: true,
slug: true,
title: true,
summary: true,
publishedAt: true,
readTime: true,
heroMediaUrl: true,
heroMediaType: true,
tags: true,
},
});
},

bySlug: async (slug: string) => {
return prisma.newsletterIssue.findUnique({
where: { slug },
include: {
sections: {
orderBy: { order: "asc" },
},
},
});
},
};
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: "assets.opensox.dev",
},
],
},
};
Expand Down
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"posthog-js": "^1.203.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-player": "^3.3.3",
"react-qr-code": "^2.0.18",
"react-tweet": "^3.2.1",
"tailwind-merge": "^2.5.4",
Expand Down
Binary file added apps/web/public/images/Screenshot-2025-10-22.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/images/welcome.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 3 additions & 3 deletions apps/web/src/app/(main)/dashboard/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,14 @@ export default function DashboardLayout({
<div className="flex w-full h-16">
<DashboardHeader></DashboardHeader>
</div>
<div className="flex flex-row w-full">
<div className="flex flex-row w-full overflow-hidden">
{showFilters && <FiltersContainer></FiltersContainer>}
<aside
className={`w-48 md:w-[40%] xl:w-[20%] ${showSidebar ? "block relative" : "hidden"} xl:block`}
className={`flex-shrink-0 w-48 md:w-[40%] xl:w-[20%] ${showSidebar ? "block relative" : "hidden"} xl:block overflow-y-auto`}
>
<Sidebar></Sidebar>
</aside>
<main className="flex-grow">{children}</main>
<main className="flex-grow overflow-y-auto">{children}</main>
</div>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"use client";

import { Suspense, lazy } from "react";
import Link from "next/link";
import { useNewsletterDetail } from "@/app/api/newsletter";
import { NewsletterIssue } from "@/data/newsletters";
import { NewsletterHero } from "@/components/newsletter/NewsletterHero";
import { NewsletterSectionRenderer } from "@/components/newsletter/NewsletterSectionRenderer";
import { EngagementBar } from "../engagementBar";

const LazyNewsletterSectionRenderer = lazy(() =>
import("@/components/newsletter/NewsletterSectionRenderer").then((mod) => ({
default: mod.NewsletterSectionRenderer,
}))
);

type NewsletterDetailClientProps = {
slug: string;
fallback: NewsletterIssue;
};

function SectionsSkeleton() {
return (
<div className="space-y-8">
{[1, 2, 3].map((i) => (
<div key={i} className="space-y-3 animate-pulse">
<div className="h-8 w-48 rounded-lg bg-ox-black-2" />
<div className="space-y-2">
<div className="h-4 w-full rounded bg-ox-black-2" />
<div className="h-4 w-5/6 rounded bg-ox-black-2" />
</div>
</div>
))}
</div>
);
}

function NavigationButtons() {
return (
<div className="flex gap-3 flex-wrap">
<Link
href="/dashboard/newsletters"
className="rounded-full border border-ox-purple px-6 py-2 text-xs font-semibold uppercase tracking-wide text-ox-purple transition hover:bg-ox-purple hover:text-ox-white"
>
Home
</Link>
<Link
href="/pricing"
className="rounded-full border border-ox-purple px-6 py-2 text-xs font-semibold uppercase tracking-wide text-ox-purple transition hover:bg-ox-purple hover:text-ox-white"
>
Pricing
</Link>
<a
href="https://discord.gg/opensox"
target="_blank"
rel="noopener noreferrer"
className="rounded-full border border-ox-purple px-6 py-2 text-xs font-semibold uppercase tracking-wide text-ox-purple transition hover:bg-ox-purple hover:text-ox-white"
>
Community
</a>
</div>
);
}

export function NewsletterDetailClient({ slug, fallback }: NewsletterDetailClientProps) {
const { data, isLoading } = useNewsletterDetail(slug);
const issue = data ?? fallback;

if (!issue) return null;

return (
<div className="flex flex-col gap-6 p-4 xl:p-6">
{/* @ts-ignore */}
<NewsletterHero issue={issue} shareButton={<EngagementBar slug={slug} />} />
<NavigationButtons />
<Suspense fallback={<SectionsSkeleton />}>
{/* @ts-ignore */}
<LazyNewsletterSectionRenderer sections={issue.sections} />
</Suspense>
</div>
);
}
19 changes: 19 additions & 0 deletions apps/web/src/app/(main)/dashboard/newsletters/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { notFound } from "next/navigation";
import { newsletterIssues } from "@/data/newsletters";
import { NewsletterDetailClient } from "./NewsletterDetailClient";

interface PageProps {
params: Promise<{ slug: string }>;
}


export default async function NewsletterDetail({ params }: PageProps) {
const { slug } = await params;
const fallback = newsletterIssues.find((issue) => issue.slug === slug);

if (!fallback) {
notFound();
}

return <NewsletterDetailClient slug={slug} fallback={fallback} />;
}
49 changes: 49 additions & 0 deletions apps/web/src/app/(main)/dashboard/newsletters/engagementBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"use client";

import { useCallback, useState } from "react";

interface EngagementBarProps {
slug: string;
}

export type { EngagementBarProps };

export function EngagementBar({ slug }: EngagementBarProps) {
const link = `/dashboard/newsletters/${slug}`;
const [copied, setCopied] = useState(false);

const handleShare = useCallback(async () => {
if (typeof window === "undefined") return;
const absoluteUrl = `${window.location.origin}${link}`;

// Try native share API first
if (navigator.share) {
try {
await navigator.share({
url: absoluteUrl,
});
return;
} catch (error) {
console.error("Share failed:", error);
}
}

// Fallback to copy to clipboard
try {
await navigator.clipboard.writeText(absoluteUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (error) {
console.error("Unable to copy link", error);
}
}, [link]);

return (
<button
onClick={handleShare}
className="rounded-full border border-ox-purple px-4 py-2 text-xs font-semibold uppercase tracking-wide text-ox-purple transition hover:bg-ox-purple hover:text-ox-white"
>
{copied ? "Copied!" : "Share"}
</button>
);
}
103 changes: 103 additions & 0 deletions apps/web/src/app/(main)/dashboard/newsletters/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"use client";

import { useMemo, useState, Suspense, lazy } from "react";
import { useNewsletterList } from "@/app/api/newsletter";
import { newsletterIssues as mockIssues, type NewsletterIssue } from "@/data/newsletters";

const LazyNewsletterCard = lazy(() =>
import("@/components/newsletter/NewsletterCard").then((mod) => ({
default: mod.NewsletterCard,
}))
);

function CardSkeleton() {
return (
<div className="h-20 animate-pulse rounded-3xl border border-ox-gray bg-ox-black-2" />
);
}

function NewsletterCardList({ issues }: { issues: NewsletterIssue[] }) {
return (
<>
{issues.map((issue: NewsletterIssue) => (
<Suspense key={issue.slug} fallback={<CardSkeleton />}>
<LazyNewsletterCard issue={issue} />
</Suspense>
))}
</>
);
}

export default function NewsletterIndex() {
const [query, setQuery] = useState("");
const { data, isLoading } = useNewsletterList(query) as {
data: NewsletterIssue[] | undefined;
isLoading: boolean;
};
const issues = data?.length ? data : mockIssues;

const filteredIssues = useMemo<NewsletterIssue[]>(() => {
const normalized = query.trim().toLowerCase();
if (!normalized) return issues as NewsletterIssue[];

return (issues as NewsletterIssue[]).filter((issue) =>
`${issue.title} ${issue.summary}`
.toLowerCase()
.includes(normalized)
);
}, [issues, query]);

return (
<div className="flex flex-col gap-6 p-4 xl:p-6">
<div className="rounded-3xl border border-ox-gray bg-ox-black-1 px-5 py-6 shadow-sm sm:px-7">
<h1 className="text-center text-3xl font-semibold text-ox-white md:text-4xl">Newsletter</h1>
{query && (
<div className="mt-4 flex w-full max-w-lg items-center rounded-full border border-ox-gray bg-ox-black-2 px-4 py-2">
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search newsletters"
disabled={isLoading}
className="w-full bg-transparent text-sm text-ox-white placeholder:text-ox-gray-light focus:outline-none"
/>
</div>
)}
</div>
<section className="grid gap-3">
{isLoading ? (
<SkeletonList />
) : filteredIssues.length > 0 ? (
<NewsletterCardList issues={filteredIssues} />
) : (
<EmptyState query={query} />
)}
</section>
</div>
);
}

function SkeletonList() {
return (
<div className="space-y-3">
{[1, 2, 3].map((key) => (
<div
key={key}
className="h-32 animate-pulse rounded-3xl border border-ox-gray bg-ox-black-2"
/>
))}
</div>
);
}

function EmptyState({ query }: { query: string }) {
return (
<div className="rounded-3xl border border-dashed border-ox-gray bg-ox-black-1 p-10 text-center">
<h2 className="text-lg font-semibold text-ox-white">No matches found</h2>
<p className="mt-2 text-sm text-ox-gray-light">
We couldn’t find any newsletters for “{query}”. Try a different keyword or clear the search
filter.
</p>
</div>
);
}
4 changes: 3 additions & 1 deletion apps/web/src/app/SessionWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,7 @@ export function SessionWrapper({
children: ReactNode;
session: Session | null;
}) {
return <SessionProvider session={session}>{children}</SessionProvider>;
return (
<SessionProvider session={session}>{children}</SessionProvider>
);
}
Loading