+
{SIDEBAR_ROUTES.map((route) => {
return (
@@ -122,6 +127,35 @@ export default function Sidebar({ overlay = false }: { overlay?: boolean }) {
);
})}
+
+
}
+ collapsed={isCollapsed}
+ />
+
+
+
+ }
+ collapsed={isCollapsed}
+ />
+ {!isCollapsed && (
+
+ PRO
+
+ )}
+
+
+
) {
+ return (
+
+ )
+}
+
+export { Skeleton }
diff --git a/apps/web/src/content/newsletters/2024-01-welcome.md b/apps/web/src/content/newsletters/2024-01-welcome.md
new file mode 100644
index 00000000..bfc9da2c
--- /dev/null
+++ b/apps/web/src/content/newsletters/2024-01-welcome.md
@@ -0,0 +1,31 @@
+---
+title: "Welcome to Opensox AI - Your Journey Begins"
+date: "2024-01-15"
+excerpt: "Introducing Opensox AI, the revolutionary platform that helps developers find the perfect open-source projects to contribute to in minutes."
+readTime: "3 min read"
+---
+
+# Welcome to Opensox AI
+
+We're thrilled to have you here! **Opensox AI** is designed to transform how developers discover and contribute to open-source projects.
+
+## What Makes Us Different?
+
+- **AI-Powered Matching**: Our intelligent system analyzes your skills and interests
+- **Curated Projects**: Every project is handpicked for quality and community
+- **Quick Discovery**: Find your perfect match in under 10 minutes
+
+## Getting Started
+
+1. Complete your profile with your skills
+2. Browse our curated project list
+3. Start contributing today!
+
+
+
+### Join Our Community
+
+Connect with thousands of developers on our [Discord server](https://discord.gg/37ke8rYnRM) and share your journey.
+
+**Happy Contributing!**
+The Opensox Team
diff --git a/apps/web/src/content/newsletters/2024-02-new-features.md b/apps/web/src/content/newsletters/2024-02-new-features.md
new file mode 100644
index 00000000..3cc0e1d0
--- /dev/null
+++ b/apps/web/src/content/newsletters/2024-02-new-features.md
@@ -0,0 +1,165 @@
+---
+title: "opensox newsletter demo - full formatting test"
+date: "2024-02-10"
+excerpt: "a stress test markdown file to validate rendering, spacing, images, lists, code, tables, and odd content."
+readTime: "7 min read"
+---
+
+# full formatting test
+
+welcome to the **opensox formatting demo**.
+this file tests every tricky markdown case that your newsletter system might hit.
+
+## headings
+
+### h3 heading
+#### h4 heading
+##### h5 heading
+
+---
+
+## paragraphs and breaks
+
+this is a normal paragraph to test spacing and line height.
+
+here is another paragraph to see if margins are correct.
+
+this is a line with
+a manual line break
+to test ` ` handling.
+
+---
+
+## bold, italics, links
+
+this is **bold text**.
+this is *italic text*.
+this is **bold and *nested italic***.
+this is a [link to opensox](https://opensox.ai).
+
+---
+
+## images
+
+
+
+
+
+both of these should render cleanly.
+
+---
+
+## lists
+
+### unordered list
+
+- item one
+- item two
+ - sub item a
+ - sub item b
+ - deep sub item
+
+### ordered list
+
+1. first
+2. second
+3. third
+ 1. nested a
+ 2. nested b
+
+---
+
+## blockquote
+
+> this is a quote block.
+> it should have left padding and a border.
+
+---
+
+## code blocks
+
+### inline code
+
+here is some inline code: `npm install opensox`.
+
+### fenced code
+
+```ts
+export function example() {
+ console.log("hello from opensox");
+ return { ok: true };
+}
+````
+
+### long code block
+
+```json
+{
+ "project": "opensox-ai",
+ "features": [
+ "ai search",
+ "project tags",
+ "user onboarding"
+ ],
+ "version": "1.0.0"
+}
+```
+
+---
+
+## table
+
+| feature | status | notes |
+| ----------- | -------- | ------------------------- |
+| ai matching | live | powered by smart scoring |
+| onboarding | improved | new user flows added |
+| news feed | coming | planned for march release |
+
+---
+
+## inline html test
+
+
+this is inline html.
+your renderer should not break when it sees light html.
+
+
+---
+
+## horizontal rules
+
+---
+
+another section after hr.
+
+---
+
+## weird characters test
+
+quotes: "hello", 'hi'
+symbols: © ® ™ ∞ ≈ ± ÷
+punctuation: … · • ° ¶ §
+
+(no em-dashes used, only hyphens)
+
+---
+
+## long paragraph stress test
+
+this is a deliberately long paragraph that exists only to test line wrapping, max width constraints, and readability under a large continuous block of text without breaks. your ui should not collapse, overflow horizontally, or produce awkward spacing when the text becomes extremely long. this type of paragraph commonly appears in newsletter intros, community messages, and deep write ups. verifying its behavior now will save you from unexpected layout issues later in production environments where user generated content appears.
+
+---
+
+## links with titles
+
+[opensox homepage](https://opensox.ai "opensox official site")
+
+---
+
+## ending message
+
+thanks for reading this giant formatting demo.
+you can delete it after testing.
+
+**the opensox team**
+
diff --git a/apps/web/src/content/newsletters/2024-03-success-stories.md b/apps/web/src/content/newsletters/2024-03-success-stories.md
new file mode 100644
index 00000000..0cf83cc8
--- /dev/null
+++ b/apps/web/src/content/newsletters/2024-03-success-stories.md
@@ -0,0 +1,58 @@
+---
+title: "Community Success Stories: March Edition"
+date: "2024-03-05"
+excerpt: "Hear inspiring stories from developers who landed their dream jobs through open-source contributions made via Opensox AI."
+readTime: "5 min read"
+---
+
+# Success Stories That Inspire Us
+
+This month, we're celebrating the **amazing achievements** of our community members who transformed their careers through open-source.
+
+## Featured Story: From Contributor to Maintainer
+
+**Alex Thompson** started contributing to a React UI library through Opensox AI last year. Today, Alex is a core maintainer of the project with over 200 contributions.
+
+> "Opensox helped me find projects that matched my skill level perfectly. The journey from first PR to maintainer was incredible!" - Alex
+
+## By The Numbers
+
+- **12,000+** projects matched
+- **8,500+** successful contributions
+- **250+** developers hired
+- **95%** satisfaction rate
+
+## Tips for Success
+
+### 1. Start Small
+Begin with "good first issue" tags to build confidence.
+
+### 2. Be Consistent
+Regular contributions matter more than large ones.
+
+### 3. Engage with Community
+Ask questions, help others, and build relationships.
+
+## Upcoming Events
+
+📅 **Open Source Workshop** - March 20th
+Learn best practices for contributing to large projects.
+
+📅 **Community Meetup** - March 28th
+Network with fellow contributors (virtual).
+
+## Premium Launch
+
+We're excited to announce **Opensox Premium** launching next month!
+
+Benefits include:
+- Priority project matching
+- Advanced analytics dashboard
+- 1-on-1 mentorship sessions
+- Early access to new features
+
+---
+
+**Keep contributing, keep growing!**
+
+[Share your story](mailto:hi@opensox.ai) | [Join Discord](https://discord.gg/37ke8rYnRM) | [Follow on X](https://x.com/ajeetunc)
diff --git a/apps/web/src/styles/newsletter.css b/apps/web/src/styles/newsletter.css
new file mode 100644
index 00000000..25849ff4
--- /dev/null
+++ b/apps/web/src/styles/newsletter.css
@@ -0,0 +1,117 @@
+/* Newsletter / markdown content styling */
+.newsletter-content {
+ @apply break-words text-sm sm:text-base text-zinc-300 leading-relaxed;
+}
+
+.newsletter-content h1 {
+ @apply text-2xl sm:text-3xl font-bold text-ox-white mt-6 sm:mt-8 mb-3 sm:mb-4 break-words;
+}
+
+.newsletter-content h2 {
+ @apply text-xl sm:text-2xl font-semibold text-ox-white mt-5 sm:mt-6 mb-2 sm:mb-3 break-words;
+}
+
+.newsletter-content h3 {
+ @apply text-lg sm:text-xl font-semibold text-zinc-200 mt-4 mb-2 break-words;
+}
+
+.newsletter-content h4 {
+ @apply text-base sm:text-lg font-semibold text-zinc-200 mt-3 mb-2 break-words;
+}
+
+.newsletter-content p {
+ @apply mb-4 break-words;
+}
+
+.newsletter-content strong {
+ @apply text-ox-white font-semibold;
+}
+
+.newsletter-content em {
+ @apply italic;
+}
+
+.newsletter-content ul,
+.newsletter-content ol {
+ @apply mb-4 ml-4 sm:ml-6 space-y-2 text-zinc-300;
+}
+
+.newsletter-content ul {
+ list-style-type: disc;
+}
+
+.newsletter-content ol {
+ list-style-type: decimal;
+}
+
+.newsletter-content li {
+ @apply break-words;
+}
+
+.newsletter-content a {
+ @apply text-ox-purple hover:text-purple-400 underline transition-colors break-all;
+}
+
+/* Make long URLs not break layout */
+.newsletter-content p>a,
+.newsletter-content li>a {
+ @apply inline-block max-w-full;
+ overflow-wrap: break-word;
+ word-wrap: break-word;
+ word-break: break-word;
+ hyphens: auto;
+}
+
+.newsletter-content img {
+ @apply rounded-lg my-4 sm:my-6 w-full h-auto max-w-full object-contain;
+ max-height: 500px;
+}
+
+/* GIFs behave like images */
+.newsletter-content img[src*=".gif"],
+.newsletter-content img[alt*="gif"] {
+ @apply object-cover;
+}
+
+.newsletter-content blockquote {
+ @apply border-l-4 border-ox-purple pl-3 sm:pl-4 italic text-sm sm:text-base text-zinc-400 my-4 break-words;
+}
+
+.newsletter-content hr {
+ @apply border-[#1a1a1d] my-6 sm:my-8;
+}
+
+.newsletter-content code {
+ @apply bg-[#121214] text-ox-purple px-1.5 sm:px-2 py-0.5 sm:py-1 rounded text-xs sm:text-sm break-all;
+}
+
+/* Code blocks */
+.newsletter-content pre {
+ @apply bg-[#121214] p-3 sm:p-4 rounded-lg overflow-x-auto my-4 text-xs sm:text-sm;
+}
+
+.newsletter-content pre code {
+ @apply bg-transparent p-0 text-xs sm:text-sm;
+}
+
+/* Tables */
+.newsletter-content table {
+ @apply w-full border-collapse my-4 text-sm sm:text-base;
+}
+
+.newsletter-content thead {
+ @apply bg-[#121214];
+}
+
+.newsletter-content th,
+.newsletter-content td {
+ @apply border border-[#1a1a1d] px-3 py-2 align-top;
+}
+
+.newsletter-content th {
+ @apply font-semibold text-ox-white;
+}
+
+.newsletter-content tbody tr:nth-child(even) {
+ @apply bg-[#0f0f11];
+}
\ No newline at end of file
diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts
index 27ec8530..db720cae 100644
--- a/apps/web/tailwind.config.ts
+++ b/apps/web/tailwind.config.ts
@@ -186,7 +186,7 @@ const config: Config = {
},
},
},
- plugins: [require("tailwindcss-animate")],
+ plugins: [require("tailwindcss-animate"), require("@tailwindcss/line-clamp")],
};
export default config;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e6fa2dbc..4f6453eb 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -146,7 +146,7 @@ importers:
specifier: ^1.2.1
version: 1.3.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-slot':
- specifier: ^1.1.0
+ specifier: ^1.2.3
version: 1.2.3(@types/react@18.3.23)(react@18.3.1)
'@tanstack/react-query':
specifier: ^5.90.2
@@ -181,9 +181,15 @@ importers:
geist:
specifier: ^1.5.1
version: 1.5.1(next@15.5.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1))
+ gray-matter:
+ specifier: ^4.0.3
+ version: 4.0.3
lucide-react:
specifier: ^0.456.0
version: 0.456.0(react@18.3.1)
+ marked:
+ specifier: ^17.0.0
+ version: 17.0.0
next:
specifier: 15.5.3
version: 15.5.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -224,6 +230,9 @@ importers:
'@types/dompurify':
specifier: ^3.2.0
version: 3.2.0
+ '@tailwindcss/line-clamp':
+ specifier: ^0.4.4
+ version: 0.4.4(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@20.19.0)(typescript@5.9.2)))
'@types/node':
specifier: ^20
version: 20.19.0
@@ -1221,6 +1230,11 @@ packages:
'@swc/helpers@0.5.5':
resolution: {integrity: sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==}
+ '@tailwindcss/line-clamp@0.4.4':
+ resolution: {integrity: sha512-5U6SY5z8N42VtrCrKlsTAA35gy2VSyYtHWCsg1H87NU1SXnEfekTVlrga9fzUDrrHcGi2Lb5KenUWb4lRQT5/g==}
+ peerDependencies:
+ tailwindcss: '>=2.0.0 || >=3.0.0 || >=3.0.0-alpha.1'
+
'@tanstack/query-core@5.90.2':
resolution: {integrity: sha512-k/TcR3YalnzibscALLwxeiLUub6jN5EDLwKDiO7q5f4ICEoptJ+n9+7vcEFy5/x/i6Q+Lb/tXrsKCggf5uQJXQ==}
@@ -2672,6 +2686,10 @@ packages:
resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==}
engines: {node: '>= 0.10.0'}
+ extend-shallow@2.0.1:
+ resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==}
+ engines: {node: '>=0.10.0'}
+
external-editor@3.1.0:
resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==}
engines: {node: '>=4'}
@@ -2912,6 +2930,10 @@ packages:
graphemer@1.4.0:
resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==}
+ gray-matter@4.0.3:
+ resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==}
+ engines: {node: '>=6.0'}
+
handlebars@4.7.8:
resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==}
engines: {node: '>=0.4.7'}
@@ -3084,6 +3106,10 @@ packages:
resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==}
engines: {node: '>= 0.4'}
+ is-extendable@0.1.1:
+ resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==}
+ engines: {node: '>=0.10.0'}
+
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
@@ -3289,6 +3315,10 @@ packages:
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
+ kind-of@6.0.3:
+ resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==}
+ engines: {node: '>=0.10.0'}
+
language-subtag-registry@0.3.23:
resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==}
@@ -3389,6 +3419,11 @@ packages:
make-error@1.3.6:
resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==}
+ marked@17.0.0:
+ resolution: {integrity: sha512-KkDYEWEEiYJw/KC+DVm1zzlpMQSMIu6YRltkcCvwheCp8HWPXCk9JwOmHJKBlGfzcpzcIt6x3sMnTsRm/51oDg==}
+ engines: {node: '>= 20'}
+ hasBin: true
+
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@@ -4073,6 +4108,10 @@ packages:
scheduler@0.23.2:
resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
+ section-matter@1.0.0:
+ resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==}
+ engines: {node: '>=4'}
+
semver-compare@1.0.0:
resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==}
@@ -4263,6 +4302,10 @@ packages:
resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==}
engines: {node: '>=12'}
+ strip-bom-string@1.0.0:
+ resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==}
+ engines: {node: '>=0.10.0'}
+
strip-bom@3.0.0:
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
engines: {node: '>=4'}
@@ -5517,6 +5560,10 @@ snapshots:
'@swc/counter': 0.1.3
tslib: 2.8.1
+ '@tailwindcss/line-clamp@0.4.4(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@20.19.0)(typescript@5.9.2)))':
+ dependencies:
+ tailwindcss: 3.4.17(ts-node@10.9.2(@types/node@20.19.0)(typescript@5.9.2))
+
'@tanstack/query-core@5.90.2': {}
'@tanstack/react-query@5.90.2(react@18.3.1)':
@@ -7421,6 +7468,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ extend-shallow@2.0.1:
+ dependencies:
+ is-extendable: 0.1.1
+
external-editor@3.1.0:
dependencies:
chardet: 0.7.0
@@ -7704,6 +7755,13 @@ snapshots:
graphemer@1.4.0: {}
+ gray-matter@4.0.3:
+ dependencies:
+ js-yaml: 3.14.1
+ kind-of: 6.0.3
+ section-matter: 1.0.0
+ strip-bom-string: 1.0.0
+
handlebars@4.7.8:
dependencies:
minimist: 1.2.8
@@ -7906,6 +7964,8 @@ snapshots:
call-bound: 1.0.4
has-tostringtag: 1.0.2
+ is-extendable@0.1.1: {}
+
is-extglob@2.1.1: {}
is-finalizationregistry@1.1.1:
@@ -8104,6 +8164,8 @@ snapshots:
dependencies:
json-buffer: 3.0.1
+ kind-of@6.0.3: {}
+
language-subtag-registry@0.3.23: {}
language-tags@1.0.9:
@@ -8188,6 +8250,8 @@ snapshots:
make-error@1.3.6: {}
+ marked@17.0.0: {}
+
math-intrinsics@1.1.0: {}
media-typer@0.3.0: {}
@@ -8905,6 +8969,11 @@ snapshots:
dependencies:
loose-envify: 1.4.0
+ section-matter@1.0.0:
+ dependencies:
+ extend-shallow: 2.0.1
+ kind-of: 6.0.3
+
semver-compare@1.0.0: {}
semver@5.7.2: {}
@@ -9182,6 +9251,8 @@ snapshots:
dependencies:
ansi-regex: 6.1.0
+ strip-bom-string@1.0.0: {}
+
strip-bom@3.0.0: {}
strip-final-newline@2.0.0: {}
From 7c7e384e21cbbbef3395dc88f1075704b8c01192 Mon Sep 17 00:00:00 2001
From: Aman Raj <113578582+huamanraj@users.noreply.github.com>
Date: Sat, 15 Nov 2025 13:41:49 +0530
Subject: [PATCH 02/12] added component based strucutre and search and filters
---
.../dashboard/newsletters/[slug]/page.tsx | 53 +-----
.../app/(main)/dashboard/newsletters/page.tsx | 156 ++++++----------
apps/web/src/app/globals.css | 2 -
.../components/newsletters/NewsletterCard.tsx | 48 +++++
.../newsletters/NewsletterFilters.tsx | 57 ++++++
.../components/newsletters/NewsletterList.tsx | 40 +++++
.../newsletters/NewsletterPagination.tsx | 89 +++++++++
.../newsletters/NewsletterSkeleton.tsx | 15 ++
.../newsletters/PremiumUpgradePrompt.tsx | 37 ++++
apps/web/src/components/ui/input-group.tsx | 170 ++++++++++++++++++
apps/web/src/components/ui/input.tsx | 22 +++
apps/web/src/components/ui/pagination.tsx | 117 ++++++++++++
apps/web/src/components/ui/textarea.tsx | 22 +++
.../content/newsletters/2024-01-welcome.md | 2 +-
.../newsletters/2024-02-new-features.md | 2 +-
.../newsletters/2024-03-success-stories.md | 2 +-
apps/web/src/hooks/useNewsletterFilters.ts | 50 ++++++
apps/web/tailwind.config.ts | 2 +-
pnpm-lock.yaml | 12 --
19 files changed, 726 insertions(+), 172 deletions(-)
create mode 100644 apps/web/src/components/newsletters/NewsletterCard.tsx
create mode 100644 apps/web/src/components/newsletters/NewsletterFilters.tsx
create mode 100644 apps/web/src/components/newsletters/NewsletterList.tsx
create mode 100644 apps/web/src/components/newsletters/NewsletterPagination.tsx
create mode 100644 apps/web/src/components/newsletters/NewsletterSkeleton.tsx
create mode 100644 apps/web/src/components/newsletters/PremiumUpgradePrompt.tsx
create mode 100644 apps/web/src/components/ui/input-group.tsx
create mode 100644 apps/web/src/components/ui/input.tsx
create mode 100644 apps/web/src/components/ui/pagination.tsx
create mode 100644 apps/web/src/components/ui/textarea.tsx
create mode 100644 apps/web/src/hooks/useNewsletterFilters.ts
diff --git a/apps/web/src/app/(main)/dashboard/newsletters/[slug]/page.tsx b/apps/web/src/app/(main)/dashboard/newsletters/[slug]/page.tsx
index be15a0f8..08b4e79c 100644
--- a/apps/web/src/app/(main)/dashboard/newsletters/[slug]/page.tsx
+++ b/apps/web/src/app/(main)/dashboard/newsletters/[slug]/page.tsx
@@ -4,10 +4,9 @@ import "@/styles/newsletter.css";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
-import { CalendarIcon, ClockIcon, ArrowLeftIcon, SparklesIcon, LockClosedIcon } from "@heroicons/react/24/outline";
+import { CalendarIcon, ClockIcon, ArrowLeftIcon } from "@heroicons/react/24/outline";
import { Skeleton } from "@/components/ui/skeleton";
import { useSubscription } from "@/hooks/useSubscription";
-import PrimaryButton from "@/components/ui/custom-button";
interface NewsletterData {
title: string;
@@ -39,55 +38,17 @@ function NewsletterSkeleton() {
);
}
-function PremiumUpgradePrompt() {
- const router = useRouter();
-
- return (
-
-
-
-
-
- Premium Content
-
-
-
- This newsletter is exclusively available for Opensox Premium members. Upgrade now to access all premium content.
-
-
-
router.push("/pricing")}
- classname="w-full sm:w-auto px-8"
- >
-
- Upgrade to Premium
-
-
-
router.push("/dashboard/newsletters")}
- className="mt-6 text-ox-purple hover:text-purple-400 transition-colors text-sm"
- >
- ← Back to newsletters
-
-
-
- );
-}
-
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 { isPaidUser, isLoading: subscriptionLoading } = useSubscription();
+ const { isLoading: subscriptionLoading } = useSubscription();
useEffect(() => {
- if (!isPaidUser || subscriptionLoading) return;
+ // Fetch for all users (testing mode)
+ if (subscriptionLoading) return;
fetch(`/api/newsletters/${slug}`)
.then((res) => res.json())
@@ -103,7 +64,7 @@ export default function NewsletterPage() {
setNewsletter(null);
setLoading(false);
});
- }, [slug, isPaidUser, subscriptionLoading]);
+ }, [slug, subscriptionLoading]);
if (subscriptionLoading) {
return (
@@ -116,10 +77,6 @@ export default function NewsletterPage() {
);
}
- if (!isPaidUser) {
- return ;
- }
-
if (loading) {
return (
diff --git a/apps/web/src/app/(main)/dashboard/newsletters/page.tsx b/apps/web/src/app/(main)/dashboard/newsletters/page.tsx
index 814e4b7b..536389ab 100644
--- a/apps/web/src/app/(main)/dashboard/newsletters/page.tsx
+++ b/apps/web/src/app/(main)/dashboard/newsletters/page.tsx
@@ -2,75 +2,30 @@
import "@/styles/newsletter.css";
-import { useEffect, useState } from "react";
-import Link from "next/link";
-import { CalendarIcon, ClockIcon, SparklesIcon, LockClosedIcon } from "@heroicons/react/24/outline";
+import { useEffect, useState, useMemo } from "react";
import { Skeleton } from "@/components/ui/skeleton";
import { useSubscription } from "@/hooks/useSubscription";
-import { useRouter } from "next/navigation";
-import PrimaryButton from "@/components/ui/custom-button";
-
-interface Newsletter {
- slug: string;
- title: string;
- date: string;
- excerpt: string;
- readTime: string;
-}
-
-function NewsletterSkeleton() {
- return (
-
- );
-}
-
-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
-
-
-
- );
-}
+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(() => {
- if (!isPaidUser || subscriptionLoading) return;
+ // Fetch newsletters for all users (testing mode)
+ if (subscriptionLoading) return;
fetch("/api/newsletters")
.then((res) => res.json())
@@ -79,7 +34,20 @@ export default function NewslettersPage() {
setLoading(false);
})
.catch(() => setLoading(false));
- }, [isPaidUser, subscriptionLoading]);
+ }, [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 (
@@ -109,51 +77,27 @@ export default function NewslettersPage() {
-
- {loading ? (
- <>
-
-
-
- >
- ) : newsletters.length === 0 ? (
-
-
No newsletters available yet.
-
- ) : (
- newsletters.map((newsletter) => (
-
-
-
- {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/app/globals.css b/apps/web/src/app/globals.css
index eae2d192..244508f1 100644
--- a/apps/web/src/app/globals.css
+++ b/apps/web/src/app/globals.css
@@ -56,9 +56,7 @@
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
-}
-@layer base {
* {
@apply border-border;
}
diff --git a/apps/web/src/components/newsletters/NewsletterCard.tsx b/apps/web/src/components/newsletters/NewsletterCard.tsx
new file mode 100644
index 00000000..578d2490
--- /dev/null
+++ b/apps/web/src/components/newsletters/NewsletterCard.tsx
@@ -0,0 +1,48 @@
+import Link from "next/link";
+import { CalendarIcon, ClockIcon } from "@heroicons/react/24/outline";
+
+export interface Newsletter {
+ slug: string;
+ title: string;
+ date: string;
+ excerpt: string;
+ readTime: string;
+}
+
+interface NewsletterCardProps {
+ newsletter: Newsletter;
+}
+
+export function NewsletterCard({ newsletter }: NewsletterCardProps) {
+ return (
+
+
+
+ {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 00000000..9b6b6302
--- /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) => (
+ onTimeFilterChange(filter.value)}
+ className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
+ timeFilter === filter.value
+ ? "bg-ox-purple text-white"
+ : "bg-[#121214] text-zinc-400 hover:text-zinc-200 border border-[#1a1a1d] hover:border-zinc-700"
+ }`}
+ >
+ {filter.label}
+
+ ))}
+
+
+ );
+}
diff --git a/apps/web/src/components/newsletters/NewsletterList.tsx b/apps/web/src/components/newsletters/NewsletterList.tsx
new file mode 100644
index 00000000..1f713f45
--- /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 00000000..8b6071fc
--- /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 00000000..4f4f4b00
--- /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 00000000..b1d67457
--- /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 00000000..07773992
--- /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 (
+
+ )
+}
+
+function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+function InputGroupInput({
+ className,
+ ...props
+}: React.ComponentProps<"input">) {
+ return (
+
+ )
+}
+
+function InputGroupTextarea({
+ className,
+ ...props
+}: React.ComponentProps<"textarea">) {
+ return (
+
+ )
+}
+
+export {
+ InputGroup,
+ InputGroupAddon,
+ InputGroupButton,
+ InputGroupText,
+ InputGroupInput,
+ InputGroupTextarea,
+}
diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx
new file mode 100644
index 00000000..69b64fb2
--- /dev/null
+++ b/apps/web/src/components/ui/input.tsx
@@ -0,0 +1,22 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+const Input = React.forwardRef>(
+ ({ className, type, ...props }, ref) => {
+ return (
+
+ )
+ }
+)
+Input.displayName = "Input"
+
+export { Input }
diff --git a/apps/web/src/components/ui/pagination.tsx b/apps/web/src/components/ui/pagination.tsx
new file mode 100644
index 00000000..d3311054
--- /dev/null
+++ b/apps/web/src/components/ui/pagination.tsx
@@ -0,0 +1,117 @@
+import * as React from "react"
+import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+import { ButtonProps, buttonVariants } from "@/components/ui/button"
+
+const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
+
+)
+Pagination.displayName = "Pagination"
+
+const PaginationContent = React.forwardRef<
+ HTMLUListElement,
+ React.ComponentProps<"ul">
+>(({ className, ...props }, ref) => (
+
+))
+PaginationContent.displayName = "PaginationContent"
+
+const PaginationItem = React.forwardRef<
+ HTMLLIElement,
+ React.ComponentProps<"li">
+>(({ className, ...props }, ref) => (
+
+))
+PaginationItem.displayName = "PaginationItem"
+
+type PaginationLinkProps = {
+ isActive?: boolean
+} & Pick &
+ React.ComponentProps<"a">
+
+const PaginationLink = ({
+ className,
+ isActive,
+ size = "icon",
+ ...props
+}: PaginationLinkProps) => (
+
+)
+PaginationLink.displayName = "PaginationLink"
+
+const PaginationPrevious = ({
+ className,
+ ...props
+}: React.ComponentProps) => (
+
+
+ Previous
+
+)
+PaginationPrevious.displayName = "PaginationPrevious"
+
+const PaginationNext = ({
+ className,
+ ...props
+}: React.ComponentProps) => (
+
+ Next
+
+
+)
+PaginationNext.displayName = "PaginationNext"
+
+const PaginationEllipsis = ({
+ className,
+ ...props
+}: React.ComponentProps<"span">) => (
+
+
+ More pages
+
+)
+PaginationEllipsis.displayName = "PaginationEllipsis"
+
+export {
+ Pagination,
+ PaginationContent,
+ PaginationLink,
+ PaginationItem,
+ PaginationPrevious,
+ PaginationNext,
+ PaginationEllipsis,
+}
diff --git a/apps/web/src/components/ui/textarea.tsx b/apps/web/src/components/ui/textarea.tsx
new file mode 100644
index 00000000..e56b0aff
--- /dev/null
+++ b/apps/web/src/components/ui/textarea.tsx
@@ -0,0 +1,22 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+const Textarea = React.forwardRef<
+ HTMLTextAreaElement,
+ React.ComponentProps<"textarea">
+>(({ className, ...props }, ref) => {
+ return (
+
+ )
+})
+Textarea.displayName = "Textarea"
+
+export { Textarea }
diff --git a/apps/web/src/content/newsletters/2024-01-welcome.md b/apps/web/src/content/newsletters/2024-01-welcome.md
index bfc9da2c..0474c1c4 100644
--- a/apps/web/src/content/newsletters/2024-01-welcome.md
+++ b/apps/web/src/content/newsletters/2024-01-welcome.md
@@ -1,6 +1,6 @@
---
title: "Welcome to Opensox AI - Your Journey Begins"
-date: "2024-01-15"
+date: "2025-01-15"
excerpt: "Introducing Opensox AI, the revolutionary platform that helps developers find the perfect open-source projects to contribute to in minutes."
readTime: "3 min read"
---
diff --git a/apps/web/src/content/newsletters/2024-02-new-features.md b/apps/web/src/content/newsletters/2024-02-new-features.md
index 3cc0e1d0..a184b65f 100644
--- a/apps/web/src/content/newsletters/2024-02-new-features.md
+++ b/apps/web/src/content/newsletters/2024-02-new-features.md
@@ -1,6 +1,6 @@
---
title: "opensox newsletter demo - full formatting test"
-date: "2024-02-10"
+date: "2025-06-10"
excerpt: "a stress test markdown file to validate rendering, spacing, images, lists, code, tables, and odd content."
readTime: "7 min read"
---
diff --git a/apps/web/src/content/newsletters/2024-03-success-stories.md b/apps/web/src/content/newsletters/2024-03-success-stories.md
index 0cf83cc8..df85a86e 100644
--- a/apps/web/src/content/newsletters/2024-03-success-stories.md
+++ b/apps/web/src/content/newsletters/2024-03-success-stories.md
@@ -1,6 +1,6 @@
---
title: "Community Success Stories: March Edition"
-date: "2024-03-05"
+date: "2025-11-08"
excerpt: "Hear inspiring stories from developers who landed their dream jobs through open-source contributions made via Opensox AI."
readTime: "5 min read"
---
diff --git a/apps/web/src/hooks/useNewsletterFilters.ts b/apps/web/src/hooks/useNewsletterFilters.ts
new file mode 100644
index 00000000..b332b0eb
--- /dev/null
+++ b/apps/web/src/hooks/useNewsletterFilters.ts
@@ -0,0 +1,50 @@
+import { useMemo } from "react";
+import { Newsletter } from "@/components/newsletters/NewsletterCard";
+import { TimeFilter } from "@/components/newsletters/NewsletterFilters";
+
+export function useNewsletterFilters(
+ newsletters: Newsletter[],
+ searchQuery: string,
+ timeFilter: TimeFilter
+) {
+ return useMemo(() => {
+ let filtered = newsletters;
+
+ // Search filter
+ if (searchQuery.trim()) {
+ const query = searchQuery.toLowerCase();
+ filtered = filtered.filter(
+ (newsletter) =>
+ newsletter.title.toLowerCase().includes(query) ||
+ newsletter.excerpt.toLowerCase().includes(query)
+ );
+ }
+
+ // Time filter
+ if (timeFilter !== "all") {
+ const now = new Date();
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
+
+ filtered = filtered.filter((newsletter) => {
+ const newsletterDate = new Date(newsletter.date);
+
+ switch (timeFilter) {
+ case "day":
+ return newsletterDate >= today;
+ case "week":
+ const weekAgo = new Date(today);
+ weekAgo.setDate(weekAgo.getDate() - 7);
+ return newsletterDate >= weekAgo;
+ case "month":
+ const monthAgo = new Date(today);
+ monthAgo.setMonth(monthAgo.getMonth() - 1);
+ return newsletterDate >= monthAgo;
+ default:
+ return true;
+ }
+ });
+ }
+
+ return filtered;
+ }, [newsletters, searchQuery, timeFilter]);
+}
diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts
index db720cae..27ec8530 100644
--- a/apps/web/tailwind.config.ts
+++ b/apps/web/tailwind.config.ts
@@ -186,7 +186,7 @@ const config: Config = {
},
},
},
- plugins: [require("tailwindcss-animate"), require("@tailwindcss/line-clamp")],
+ plugins: [require("tailwindcss-animate")],
};
export default config;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4f6453eb..d156b1f7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -230,9 +230,6 @@ importers:
'@types/dompurify':
specifier: ^3.2.0
version: 3.2.0
- '@tailwindcss/line-clamp':
- specifier: ^0.4.4
- version: 0.4.4(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@20.19.0)(typescript@5.9.2)))
'@types/node':
specifier: ^20
version: 20.19.0
@@ -1230,11 +1227,6 @@ packages:
'@swc/helpers@0.5.5':
resolution: {integrity: sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==}
- '@tailwindcss/line-clamp@0.4.4':
- resolution: {integrity: sha512-5U6SY5z8N42VtrCrKlsTAA35gy2VSyYtHWCsg1H87NU1SXnEfekTVlrga9fzUDrrHcGi2Lb5KenUWb4lRQT5/g==}
- peerDependencies:
- tailwindcss: '>=2.0.0 || >=3.0.0 || >=3.0.0-alpha.1'
-
'@tanstack/query-core@5.90.2':
resolution: {integrity: sha512-k/TcR3YalnzibscALLwxeiLUub6jN5EDLwKDiO7q5f4ICEoptJ+n9+7vcEFy5/x/i6Q+Lb/tXrsKCggf5uQJXQ==}
@@ -5560,10 +5552,6 @@ snapshots:
'@swc/counter': 0.1.3
tslib: 2.8.1
- '@tailwindcss/line-clamp@0.4.4(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@20.19.0)(typescript@5.9.2)))':
- dependencies:
- tailwindcss: 3.4.17(ts-node@10.9.2(@types/node@20.19.0)(typescript@5.9.2))
-
'@tanstack/query-core@5.90.2': {}
'@tanstack/react-query@5.90.2(react@18.3.1)':
From 9e40de72a158204f293690e6aec42d67ab28f5dd Mon Sep 17 00:00:00 2001
From: Aman Raj <113578582+huamanraj@users.noreply.github.com>
Date: Mon, 17 Nov 2025 18:33:20 +0530
Subject: [PATCH 03/12] feat(newsletters): enhance newsletter ui
---
.../dashboard/newsletters/[slug]/page.tsx | 145 +++++++++++++-----
.../app/(main)/dashboard/newsletters/page.tsx | 73 +++++----
apps/web/src/app/api/newsletters/route.ts | 3 +-
apps/web/src/components/dashboard/Sidebar.tsx | 26 +++-
.../components/newsletters/NewsletterCard.tsx | 59 +++----
.../newsletters/NewsletterFilters.tsx | 77 ++++++----
.../components/newsletters/NewsletterList.tsx | 20 ++-
.../newsletters/NewsletterSkeleton.tsx | 18 ++-
apps/web/src/hooks/useNewsletterFilters.ts | 31 +---
apps/web/src/styles/newsletter.css | 78 ++++++++++
10 files changed, 345 insertions(+), 185 deletions(-)
diff --git a/apps/web/src/app/(main)/dashboard/newsletters/[slug]/page.tsx b/apps/web/src/app/(main)/dashboard/newsletters/[slug]/page.tsx
index 08b4e79c..d05155af 100644
--- a/apps/web/src/app/(main)/dashboard/newsletters/[slug]/page.tsx
+++ b/apps/web/src/app/(main)/dashboard/newsletters/[slug]/page.tsx
@@ -7,6 +7,7 @@ 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";
+import { PremiumUpgradePrompt } from "@/components/newsletters/PremiumUpgradePrompt";
interface NewsletterData {
title: string;
@@ -17,22 +18,64 @@ interface NewsletterData {
function NewsletterSkeleton() {
return (
-
-
-
-
-
+
+ {/* Header skeleton */}
+
-
-
-
-
-
-
-
+
+ {/* Content skeleton */}
+
+ {/* Paragraph 1 */}
+
+
+
+
+
+
+ {/* Heading */}
+
+
+ {/* Paragraph 2 */}
+
+
+
+
+
+
+
+ {/* Image placeholder */}
+
+
+ {/* Heading */}
+
+
+ {/* Paragraph 3 */}
+
+
+
+
+
+
+ {/* List items */}
+
+
+
+
+
+
+ {/* Final paragraph */}
+
+
+
+
+
);
@@ -44,10 +87,9 @@ export default function NewsletterPage() {
const slug = params.slug as string;
const [newsletter, setNewsletter] = useState
(null);
const [loading, setLoading] = useState(true);
- const { isLoading: subscriptionLoading } = useSubscription();
+ const { isPaidUser, isLoading: subscriptionLoading } = useSubscription();
useEffect(() => {
- // Fetch for all users (testing mode)
if (subscriptionLoading) return;
fetch(`/api/newsletters/${slug}`)
@@ -69,19 +111,23 @@ export default function NewsletterPage() {
if (subscriptionLoading) {
return (
-
);
}
+ if (!isPaidUser) {
+ return
;
+ }
+
if (loading) {
return (
-
@@ -92,12 +138,14 @@ export default function NewsletterPage() {
return (
-
Newsletter not found
+
Newsletter not found
+
The newsletter you're looking for doesn't exist.
router.push("/dashboard/newsletters")}
- className="text-ox-purple hover:text-purple-400 transition-colors"
+ className="inline-flex items-center gap-2 px-4 py-2 bg-ox-purple hover:bg-purple-600 text-white text-sm rounded-lg transition-colors"
>
- ← Back to newsletters
+
+ Back to newsletters
@@ -106,40 +154,57 @@ export default function NewsletterPage() {
return (
-
+
+ {/* Back Button */}
router.back()}
- className="flex items-center gap-2 text-ox-purple hover:text-purple-400 mb-6 transition-colors"
+ className="inline-flex items-center gap-1.5 text-zinc-400 hover:text-white mb-6 transition-colors text-sm group"
>
-
- Back to newsletters
+
+ Back
-
-
-
+
+ {/* Header */}
+
+
{newsletter.title}
-
-
+
+ {/* Metadata */}
+
+
-
- {new Date(newsletter.date).toLocaleDateString("en-US", {
- month: "long",
- day: "numeric",
- year: "numeric",
- })}
-
+ {new Date(newsletter.date).toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ })}
-
+ •
+
- {newsletter.readTime}
+ {newsletter.readTime}
+ {/* Content */}
diff --git a/apps/web/src/app/(main)/dashboard/newsletters/page.tsx b/apps/web/src/app/(main)/dashboard/newsletters/page.tsx
index 536389ab..e16cca5a 100644
--- a/apps/web/src/app/(main)/dashboard/newsletters/page.tsx
+++ b/apps/web/src/app/(main)/dashboard/newsletters/page.tsx
@@ -8,7 +8,7 @@ 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 { NewsletterFilters, TimeFilter, SortFilter } from "@/components/newsletters/NewsletterFilters";
import { NewsletterPagination } from "@/components/newsletters/NewsletterPagination";
import { NewsletterList } from "@/components/newsletters/NewsletterList";
import { useNewsletterFilters } from "@/hooks/useNewsletterFilters";
@@ -18,13 +18,13 @@ export default function NewslettersPage() {
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState("");
const [timeFilter, setTimeFilter] = useState("all");
+ const [sortFilter, setSortFilter] = useState("newest");
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")
@@ -38,21 +38,32 @@ export default function NewslettersPage() {
const filteredNewsletters = useNewsletterFilters(newsletters, searchQuery, timeFilter);
- const totalPages = Math.ceil(filteredNewsletters.length / itemsPerPage);
+ // Apply sorting
+ const sortedNewsletters = useMemo(() => {
+ const sorted = [...filteredNewsletters];
+ sorted.sort((a, b) => {
+ const dateA = new Date(a.date).getTime();
+ const dateB = new Date(b.date).getTime();
+ return sortFilter === "newest" ? dateB - dateA : dateA - dateB;
+ });
+ return sorted;
+ }, [filteredNewsletters, sortFilter]);
+
+ const totalPages = Math.ceil(sortedNewsletters.length / itemsPerPage);
const paginatedNewsletters = useMemo(() => {
const startIndex = (currentPage - 1) * itemsPerPage;
- return filteredNewsletters.slice(startIndex, startIndex + itemsPerPage);
- }, [filteredNewsletters, currentPage]);
+ return sortedNewsletters.slice(startIndex, startIndex + itemsPerPage);
+ }, [sortedNewsletters, currentPage]);
// Reset to page 1 when filters change
useEffect(() => {
setCurrentPage(1);
- }, [searchQuery, timeFilter]);
+ }, [searchQuery, timeFilter, sortFilter]);
if (subscriptionLoading) {
return (
-
+
@@ -66,39 +77,39 @@ export default function NewslettersPage() {
}
return (
-
-
+
+
-
- Newsletters
+
+ Newsletter
- Stay updated with our latest news and insights
+ Stay updated with our latest insights and stories
-
+
+
);
diff --git a/apps/web/src/app/api/newsletters/route.ts b/apps/web/src/app/api/newsletters/route.ts
index 179b8426..0b62c607 100644
--- a/apps/web/src/app/api/newsletters/route.ts
+++ b/apps/web/src/app/api/newsletters/route.ts
@@ -34,11 +34,12 @@ export async function GET() {
const { data } = matter(fileContent);
return {
- slug: file.replace(".md", ""),
+ id: file.replace(".md", ""),
title: data.title || "Untitled",
date: data.date || new Date().toISOString(),
excerpt: data.excerpt || "",
readTime: data.readTime || "5 min read",
+ description: data.description || data.excerpt || "",
};
})
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
diff --git a/apps/web/src/components/dashboard/Sidebar.tsx b/apps/web/src/components/dashboard/Sidebar.tsx
index d284f6a2..15749cb2 100644
--- a/apps/web/src/components/dashboard/Sidebar.tsx
+++ b/apps/web/src/components/dashboard/Sidebar.tsx
@@ -44,6 +44,12 @@ const SIDEBAR_ROUTES = [
label: "OSS Sheet",
icon:
,
},
+ {
+ path: "/dashboard/newsletters",
+ label: "Newsletters",
+ icon:
,
+ isPro: true,
+ },
];
export default function Sidebar({ overlay = false }: { overlay?: boolean }) {
@@ -119,11 +125,21 @@ export default function Sidebar({ overlay = false }: { overlay?: boolean }) {
{SIDEBAR_ROUTES.map((route) => {
return (
-
+
+
+ {route.icon}
+
+ {!isCollapsed && (
+
+
+ {route.label}
+
+ {route.isPro && (
+
+ )}
+
+ )}
+
);
})}
diff --git a/apps/web/src/components/newsletters/NewsletterCard.tsx b/apps/web/src/components/newsletters/NewsletterCard.tsx
index 578d2490..4c5be3a8 100644
--- a/apps/web/src/components/newsletters/NewsletterCard.tsx
+++ b/apps/web/src/components/newsletters/NewsletterCard.tsx
@@ -1,48 +1,39 @@
import Link from "next/link";
-import { CalendarIcon, ClockIcon } from "@heroicons/react/24/outline";
export interface Newsletter {
- slug: string;
+ id: string;
title: string;
+ description: string;
+ excerpt?: string;
date: string;
- excerpt: string;
readTime: string;
+ tags?: string[];
}
-interface NewsletterCardProps {
- newsletter: Newsletter;
-}
-
-export function NewsletterCard({ newsletter }: NewsletterCardProps) {
+export function NewsletterCard({ newsletter }: { newsletter: Newsletter }) {
return (
-
-
-
- {newsletter.title}
-
-
-
-
-
- {new Date(newsletter.date).toLocaleDateString("en-US", {
- month: "short",
- day: "numeric",
- year: "numeric",
- })}
+
+
+
+
+
+ {newsletter.title}
+
+
+ {newsletter.excerpt || newsletter.description}
+
+
+
+
{newsletter.date}
+
+
+
+
+ {newsletter.readTime}
-
-
-
- {newsletter.readTime}
-
+
-
- {newsletter.excerpt}
-
-
+
);
}
diff --git a/apps/web/src/components/newsletters/NewsletterFilters.tsx b/apps/web/src/components/newsletters/NewsletterFilters.tsx
index 9b6b6302..024003ff 100644
--- a/apps/web/src/components/newsletters/NewsletterFilters.tsx
+++ b/apps/web/src/components/newsletters/NewsletterFilters.tsx
@@ -1,13 +1,15 @@
-import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
-import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
+import { Search } from "lucide-react";
-export type TimeFilter = "all" | "day" | "week" | "month";
+export type TimeFilter = "all" | "january" | "february" | "march" | "april" | "may" | "june" | "july" | "august" | "september" | "october" | "november" | "december";
+export type SortFilter = "newest" | "oldest";
interface NewsletterFiltersProps {
searchQuery: string;
onSearchChange: (query: string) => void;
timeFilter: TimeFilter;
onTimeFilterChange: (filter: TimeFilter) => void;
+ sortFilter?: SortFilter;
+ onSortFilterChange?: (filter: SortFilter) => void;
}
export function NewsletterFilters({
@@ -15,43 +17,52 @@ export function NewsletterFilters({
onSearchChange,
timeFilter,
onTimeFilterChange,
+ sortFilter = "newest",
+ onSortFilterChange,
}: 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)}
+ className="w-full bg-[#111111] border border-zinc-800 rounded-lg pl-11 pr-4 py-2.5 text-sm text-white placeholder:text-zinc-500 focus:outline-none focus:border-zinc-700 focus:ring-1 focus:ring-zinc-700 transition-all"
/>
-
-
-
- {filters.map((filter) => (
- onTimeFilterChange(filter.value)}
- className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
- timeFilter === filter.value
- ? "bg-ox-purple text-white"
- : "bg-[#121214] text-zinc-400 hover:text-zinc-200 border border-[#1a1a1d] hover:border-zinc-700"
- }`}
- >
- {filter.label}
-
- ))}
+
+
onTimeFilterChange(e.target.value as TimeFilter)}
+ className="bg-[#111111] border border-zinc-800 rounded-lg px-4 py-2.5 text-sm text-white focus:outline-none focus:border-zinc-700 focus:ring-1 focus:ring-zinc-700 transition-all cursor-pointer min-w-[140px]"
+ >
+ All Months
+ January
+ February
+ March
+ April
+ May
+ June
+ July
+ August
+ September
+ October
+ November
+ December
+
+
+ {onSortFilterChange && (
+
onSortFilterChange(e.target.value as SortFilter)}
+ className="bg-[#111111] border border-zinc-800 rounded-lg px-4 py-2.5 text-sm text-white focus:outline-none focus:border-zinc-700 focus:ring-1 focus:ring-zinc-700 transition-all cursor-pointer min-w-[140px]"
+ >
+ Newest First
+ Oldest First
+
+ )}
);
}
diff --git a/apps/web/src/components/newsletters/NewsletterList.tsx b/apps/web/src/components/newsletters/NewsletterList.tsx
index 1f713f45..ef37c431 100644
--- a/apps/web/src/components/newsletters/NewsletterList.tsx
+++ b/apps/web/src/components/newsletters/NewsletterList.tsx
@@ -10,31 +10,29 @@ interface NewsletterListProps {
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."}
+
+
+ {hasFilters ? "No newsletters found matching your filters." : "No newsletters available yet."}
);
}
return (
- <>
- {newsletters.map((newsletter) => (
-
+
+ {newsletters.map((newsletter, index) => (
+
))}
- >
+
);
}
diff --git a/apps/web/src/components/newsletters/NewsletterSkeleton.tsx b/apps/web/src/components/newsletters/NewsletterSkeleton.tsx
index 4f4f4b00..d2e8bb15 100644
--- a/apps/web/src/components/newsletters/NewsletterSkeleton.tsx
+++ b/apps/web/src/components/newsletters/NewsletterSkeleton.tsx
@@ -2,14 +2,18 @@ import { Skeleton } from "@/components/ui/skeleton";
export function NewsletterSkeleton() {
return (
-
-
-
-
-
+
);
}
diff --git a/apps/web/src/hooks/useNewsletterFilters.ts b/apps/web/src/hooks/useNewsletterFilters.ts
index b332b0eb..824a11de 100644
--- a/apps/web/src/hooks/useNewsletterFilters.ts
+++ b/apps/web/src/hooks/useNewsletterFilters.ts
@@ -8,40 +8,25 @@ export function useNewsletterFilters(
timeFilter: TimeFilter
) {
return useMemo(() => {
- let filtered = newsletters;
+ let filtered = [...newsletters];
- // Search filter
+ // Apply search filter
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(
(newsletter) =>
newsletter.title.toLowerCase().includes(query) ||
- newsletter.excerpt.toLowerCase().includes(query)
+ newsletter.description?.toLowerCase().includes(query) ||
+ newsletter.excerpt?.toLowerCase().includes(query)
);
}
- // Time filter
+ // Apply time filter
if (timeFilter !== "all") {
- const now = new Date();
- const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
-
filtered = filtered.filter((newsletter) => {
- const newsletterDate = new Date(newsletter.date);
-
- switch (timeFilter) {
- case "day":
- return newsletterDate >= today;
- case "week":
- const weekAgo = new Date(today);
- weekAgo.setDate(weekAgo.getDate() - 7);
- return newsletterDate >= weekAgo;
- case "month":
- const monthAgo = new Date(today);
- monthAgo.setMonth(monthAgo.getMonth() - 1);
- return newsletterDate >= monthAgo;
- default:
- return true;
- }
+ const date = new Date(newsletter.date);
+ const month = date.toLocaleString("en-US", { month: "long" }).toLowerCase();
+ return month === timeFilter;
});
}
diff --git a/apps/web/src/styles/newsletter.css b/apps/web/src/styles/newsletter.css
index 25849ff4..b6dbeaa1 100644
--- a/apps/web/src/styles/newsletter.css
+++ b/apps/web/src/styles/newsletter.css
@@ -114,4 +114,82 @@
.newsletter-content tbody tr:nth-child(even) {
@apply bg-[#0f0f11];
+}
+
+/* Enhanced Newsletter Content Styles */
+.newsletter-content-wrapper {
+ position: relative;
+ background: linear-gradient(to bottom, transparent 0%, rgba(139, 92, 246, 0.02) 50%, transparent 100%);
+ padding: 2rem 0;
+ margin: 0 -1rem;
+ padding-left: 1rem;
+ padding-right: 1rem;
+}
+
+@media (min-width: 640px) {
+ .newsletter-content-wrapper {
+ margin: 0 -2rem;
+ padding-left: 2rem;
+ padding-right: 2rem;
+ }
+}
+
+/* Minimal Newsletter Content Styles */
+.newsletter-content h2 {
+ position: relative;
+
+}
+
+.newsletter-content img {
+ margin-top: 1.5rem;
+ margin-bottom: 1.5rem;
+}
+
+.newsletter-content ul,
+.newsletter-content ol {
+ margin-left: 1.25rem;
+}
+
+.newsletter-content li::marker {
+ color: rgb(139, 92, 246);
+}
+
+.newsletter-content table {
+ border-collapse: collapse;
+ width: 100%;
+ margin: 1.5rem 0;
+}
+
+.newsletter-content th,
+.newsletter-content td {
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ padding: 0.5rem 0.75rem;
+ text-align: left;
+ font-size: 0.875rem;
+}
+
+.newsletter-content th {
+ background: rgba(139, 92, 246, 0.1);
+ color: white;
+ font-weight: 500;
+}
+
+.newsletter-content td {
+ color: rgb(212, 212, 216);
+}
+
+/* Smooth reading experience */
+.newsletter-article {
+ animation: fadeIn 0.5s ease-in;
+}
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
}
\ No newline at end of file
From 8188882b9990f61736d3c02a9b60f550d1eb3389 Mon Sep 17 00:00:00 2001
From: Aman Raj <113578582+huamanraj@users.noreply.github.com>
Date: Mon, 17 Nov 2025 22:26:43 +0530
Subject: [PATCH 04/12] feat(newsletters): add error handling for unauthorized
and forbidden access
---
.../dashboard/newsletters/[slug]/page.tsx | 34 +++++++++++++++----
.../app/(main)/dashboard/newsletters/page.tsx | 25 ++++++++++++--
.../src/app/api/newsletters/[slug]/route.ts | 31 +++++++++++++++++
apps/web/src/app/api/newsletters/route.ts | 31 +++++++++++++++++
4 files changed, 112 insertions(+), 9 deletions(-)
diff --git a/apps/web/src/app/(main)/dashboard/newsletters/[slug]/page.tsx b/apps/web/src/app/(main)/dashboard/newsletters/[slug]/page.tsx
index d05155af..8bcccfdd 100644
--- a/apps/web/src/app/(main)/dashboard/newsletters/[slug]/page.tsx
+++ b/apps/web/src/app/(main)/dashboard/newsletters/[slug]/page.tsx
@@ -87,23 +87,40 @@ export default function NewsletterPage() {
const slug = params.slug as string;
const [newsletter, setNewsletter] = useState
(null);
const [loading, setLoading] = useState(true);
+ const [error, setError] = useState<'unauthorized' | 'forbidden' | 'not-found' | null>(null);
const { isPaidUser, isLoading: subscriptionLoading } = useSubscription();
useEffect(() => {
if (subscriptionLoading) return;
fetch(`/api/newsletters/${slug}`)
- .then((res) => res.json())
+ .then(async (res) => {
+ if (res.status === 401) {
+ setError('unauthorized');
+ setLoading(false);
+ return null;
+ }
+ if (res.status === 403) {
+ setError('forbidden');
+ setLoading(false);
+ return null;
+ }
+ if (!res.ok) {
+ setError('not-found');
+ setLoading(false);
+ return null;
+ }
+ return res.json();
+ })
.then((data) => {
- if (data.error) {
- setNewsletter(null);
- } else {
+ if (data && !data.error) {
setNewsletter(data);
+ setError(null);
}
setLoading(false);
})
.catch(() => {
- setNewsletter(null);
+ setError('not-found');
setLoading(false);
});
}, [slug, subscriptionLoading]);
@@ -119,10 +136,15 @@ export default function NewsletterPage() {
);
}
- if (!isPaidUser) {
+ if (!isPaidUser || error === 'forbidden') {
return ;
}
+ if (error === 'unauthorized') {
+ router.push('/login');
+ return null;
+ }
+
if (loading) {
return (
diff --git a/apps/web/src/app/(main)/dashboard/newsletters/page.tsx b/apps/web/src/app/(main)/dashboard/newsletters/page.tsx
index e16cca5a..1f829cb6 100644
--- a/apps/web/src/app/(main)/dashboard/newsletters/page.tsx
+++ b/apps/web/src/app/(main)/dashboard/newsletters/page.tsx
@@ -16,6 +16,7 @@ import { useNewsletterFilters } from "@/hooks/useNewsletterFilters";
export default function NewslettersPage() {
const [newsletters, setNewsletters] = useState
([]);
const [loading, setLoading] = useState(true);
+ const [error, setError] = useState<'unauthorized' | 'forbidden' | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [timeFilter, setTimeFilter] = useState("all");
const [sortFilter, setSortFilter] = useState("newest");
@@ -28,9 +29,27 @@ export default function NewslettersPage() {
if (subscriptionLoading) return;
fetch("/api/newsletters")
- .then((res) => res.json())
+ .then(async (res) => {
+ if (res.status === 401) {
+ setError('unauthorized');
+ setLoading(false);
+ return null;
+ }
+ if (res.status === 403) {
+ setError('forbidden');
+ setLoading(false);
+ return null;
+ }
+ if (!res.ok) {
+ setLoading(false);
+ return null;
+ }
+ return res.json();
+ })
.then((data) => {
- setNewsletters(data);
+ if (data) {
+ setNewsletters(data);
+ }
setLoading(false);
})
.catch(() => setLoading(false));
@@ -72,7 +91,7 @@ export default function NewslettersPage() {
);
}
- if (!isPaidUser) {
+ if (!isPaidUser || error === 'forbidden') {
return ;
}
diff --git a/apps/web/src/app/api/newsletters/[slug]/route.ts b/apps/web/src/app/api/newsletters/[slug]/route.ts
index 31483228..998bc84c 100644
--- a/apps/web/src/app/api/newsletters/[slug]/route.ts
+++ b/apps/web/src/app/api/newsletters/[slug]/route.ts
@@ -3,6 +3,9 @@ import fs from "fs";
import path from "path";
import matter from "gray-matter";
import { marked } from "marked";
+import { getServerSession } from "next-auth";
+import { authConfig } from "@/lib/auth/config";
+import { serverTrpc } from "@/lib/trpc-server";
// Configure marked for rich markdown support
marked.setOptions({
@@ -18,6 +21,34 @@ export async function GET(
_request: Request,
{ params }: { params: Promise<{ slug: string }> }
) {
+ // Authenticate user
+ const session = await getServerSession(authConfig);
+
+ if (!session || !session.user?.email) {
+ return NextResponse.json(
+ { error: "Unauthorized - Please sign in" },
+ { status: 401 }
+ );
+ }
+
+ // Verify paid subscription
+ try {
+ const subscriptionStatus = await serverTrpc.user.subscriptionStatus.query();
+
+ if (!subscriptionStatus.isPaidUser) {
+ return NextResponse.json(
+ { error: "Forbidden - Premium subscription required" },
+ { status: 403 }
+ );
+ }
+ } catch (error) {
+ console.error("Error checking subscription:", error);
+ return NextResponse.json(
+ { error: "Failed to verify subscription status" },
+ { status: 500 }
+ );
+ }
+
const { slug } = await params;
const now = Date.now();
const cached = newsletterCache.get(slug);
diff --git a/apps/web/src/app/api/newsletters/route.ts b/apps/web/src/app/api/newsletters/route.ts
index 0b62c607..15734d40 100644
--- a/apps/web/src/app/api/newsletters/route.ts
+++ b/apps/web/src/app/api/newsletters/route.ts
@@ -2,6 +2,9 @@ import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import matter from "gray-matter";
+import { getServerSession } from "next-auth";
+import { authConfig } from "@/lib/auth/config";
+import { serverTrpc } from "@/lib/trpc-server";
// Cache newsletters in memory for faster subsequent loads
let cachedNewsletters: any[] | null = null;
@@ -9,6 +12,34 @@ let lastCacheTime = 0;
const CACHE_DURATION = 60000; // 1 minute cache
export async function GET() {
+ // Authenticate user
+ const session = await getServerSession(authConfig);
+
+ if (!session || !session.user?.email) {
+ return NextResponse.json(
+ { error: "Unauthorized - Please sign in" },
+ { status: 401 }
+ );
+ }
+
+ // Verify paid subscription
+ try {
+ const subscriptionStatus = await serverTrpc.user.subscriptionStatus.query();
+
+ if (!subscriptionStatus.isPaidUser) {
+ return NextResponse.json(
+ { error: "Forbidden - Premium subscription required" },
+ { status: 403 }
+ );
+ }
+ } catch (error) {
+ console.error("Error checking subscription:", error);
+ return NextResponse.json(
+ { error: "Failed to verify subscription status" },
+ { status: 500 }
+ );
+ }
+
const now = Date.now();
// Return cached data if available and fresh
From d26ab13f0abafdf56e665496450e5f803bdf28c6 Mon Sep 17 00:00:00 2001
From: Aman Raj <113578582+huamanraj@users.noreply.github.com>
Date: Wed, 19 Nov 2025 19:09:02 +0530
Subject: [PATCH 05/12] feat(newsletters): implement error handling for
unauthorized access and improve newsletter UI styling
---
.../app/(main)/dashboard/newsletters/page.tsx | 7 +++
apps/web/src/app/api/newsletters/route.ts | 7 ++-
apps/web/src/components/dashboard/Sidebar.tsx | 27 +----------
apps/web/src/styles/newsletter.css | 8 ++--
pnpm-lock.yaml | 47 +++++++++----------
5 files changed, 39 insertions(+), 57 deletions(-)
diff --git a/apps/web/src/app/(main)/dashboard/newsletters/page.tsx b/apps/web/src/app/(main)/dashboard/newsletters/page.tsx
index 1f829cb6..288dd326 100644
--- a/apps/web/src/app/(main)/dashboard/newsletters/page.tsx
+++ b/apps/web/src/app/(main)/dashboard/newsletters/page.tsx
@@ -3,6 +3,7 @@
import "@/styles/newsletter.css";
import { useEffect, useState, useMemo } from "react";
+import { useRouter } from "next/navigation";
import { Skeleton } from "@/components/ui/skeleton";
import { useSubscription } from "@/hooks/useSubscription";
import { Newsletter } from "@/components/newsletters/NewsletterCard";
@@ -14,6 +15,7 @@ import { NewsletterList } from "@/components/newsletters/NewsletterList";
import { useNewsletterFilters } from "@/hooks/useNewsletterFilters";
export default function NewslettersPage() {
+ const router = useRouter();
const [newsletters, setNewsletters] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<'unauthorized' | 'forbidden' | null>(null);
@@ -91,6 +93,11 @@ export default function NewslettersPage() {
);
}
+ if (error === 'unauthorized') {
+ router.push('/login');
+ return null;
+ }
+
if (!isPaidUser || error === 'forbidden') {
return ;
}
diff --git a/apps/web/src/app/api/newsletters/route.ts b/apps/web/src/app/api/newsletters/route.ts
index 15734d40..218a619a 100644
--- a/apps/web/src/app/api/newsletters/route.ts
+++ b/apps/web/src/app/api/newsletters/route.ts
@@ -24,7 +24,7 @@ export async function GET() {
// Verify paid subscription
try {
- const subscriptionStatus = await serverTrpc.user.subscriptionStatus.query();
+ const subscriptionStatus = await serverTrpc(session).user.subscriptionStatus.query();
if (!subscriptionStatus.isPaidUser) {
return NextResponse.json(
@@ -82,6 +82,9 @@ export async function GET() {
return NextResponse.json(newsletters);
} catch (error) {
console.error("Error reading newsletters:", error);
- return NextResponse.json([]);
+ return NextResponse.json(
+ { error: "Failed to read newsletters" },
+ { status: 500 }
+ );
}
}
diff --git a/apps/web/src/components/dashboard/Sidebar.tsx b/apps/web/src/components/dashboard/Sidebar.tsx
index 15749cb2..844e92b3 100644
--- a/apps/web/src/components/dashboard/Sidebar.tsx
+++ b/apps/web/src/components/dashboard/Sidebar.tsx
@@ -144,33 +144,8 @@ export default function Sidebar({ overlay = false }: { overlay?: boolean }) {
);
})}
- }
- collapsed={isCollapsed}
- />
-
-
- }
- collapsed={isCollapsed}
- />
- {!isCollapsed && (
-
- PRO
-
- )}
-
-
+
=2.0.0 || >=3.0.0 || >=3.0.0-alpha.1'
+
'@tanstack/query-core@5.90.2':
resolution: {integrity: sha512-k/TcR3YalnzibscALLwxeiLUub6jN5EDLwKDiO7q5f4ICEoptJ+n9+7vcEFy5/x/i6Q+Lb/tXrsKCggf5uQJXQ==}
@@ -5552,6 +5560,10 @@ snapshots:
'@swc/counter': 0.1.3
tslib: 2.8.1
+ '@tailwindcss/line-clamp@0.4.4(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@20.19.0)(typescript@5.9.2)))':
+ dependencies:
+ tailwindcss: 3.4.17(ts-node@10.9.2(@types/node@20.19.0)(typescript@5.9.2))
+
'@tanstack/query-core@5.90.2': {}
'@tanstack/react-query@5.90.2(react@18.3.1)':
@@ -6983,7 +6995,7 @@ snapshots:
eslint: 8.57.1
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0(eslint@8.57.1))(eslint@8.57.1)
- eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
+ eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1)
eslint-plugin-react: 7.37.5(eslint@8.57.1)
eslint-plugin-react-hooks: 5.0.0-canary-7118f5dd7-20230705(eslint@8.57.1)
@@ -7002,8 +7014,8 @@ snapshots:
'@typescript-eslint/parser': 8.34.0(eslint@8.57.1)(typescript@5.9.2)
eslint: 8.57.1
eslint-import-resolver-node: 0.3.9
- eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.34.0(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1))(eslint@8.57.1)
- eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.34.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0)(eslint@8.57.1)
+ eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.34.0(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1)
eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1)
eslint-plugin-react: 7.37.5(eslint@8.57.1)
eslint-plugin-react-hooks: 5.2.0(eslint@8.57.1)
@@ -7036,21 +7048,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.34.0(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1))(eslint@8.57.1):
- dependencies:
- '@nolyfill/is-core-module': 1.0.39
- debug: 4.4.1
- eslint: 8.57.1
- get-tsconfig: 4.10.1
- is-bun-module: 2.0.0
- stable-hash: 0.0.5
- tinyglobby: 0.2.14
- unrs-resolver: 1.9.0
- optionalDependencies:
- eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.34.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
- transitivePeerDependencies:
- - supports-color
-
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(eslint@8.57.1))(eslint@8.57.1):
dependencies:
'@nolyfill/is-core-module': 1.0.39
@@ -7062,7 +7059,7 @@ snapshots:
tinyglobby: 0.2.14
unrs-resolver: 1.9.0
optionalDependencies:
- eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
+ eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
transitivePeerDependencies:
- supports-color
@@ -7077,7 +7074,7 @@ snapshots:
tinyglobby: 0.2.14
unrs-resolver: 1.9.0
optionalDependencies:
- eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1)
+ eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.34.0(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1)
transitivePeerDependencies:
- supports-color
@@ -7102,14 +7099,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-module-utils@2.12.0(@typescript-eslint/parser@8.34.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.34.0(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1):
+ eslint-module-utils@2.12.0(@typescript-eslint/parser@8.34.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 8.34.0(eslint@8.57.1)(typescript@5.9.2)
eslint: 8.57.1
eslint-import-resolver-node: 0.3.9
- eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.34.0(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1))(eslint@8.57.1)
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0)(eslint@8.57.1)
transitivePeerDependencies:
- supports-color
@@ -7148,7 +7145,7 @@ snapshots:
- eslint-import-resolver-webpack
- supports-color
- eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1):
+ eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@@ -7177,7 +7174,7 @@ snapshots:
- eslint-import-resolver-webpack
- supports-color
- eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.34.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1):
+ eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.34.0(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@@ -7188,7 +7185,7 @@ snapshots:
doctrine: 2.1.0
eslint: 8.57.1
eslint-import-resolver-node: 0.3.9
- eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.34.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.34.0(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
+ eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.34.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3
From d87d00eaa19a244fdaa0d8d0e57e4cceb941c47c Mon Sep 17 00:00:00 2001
From: Aman Raj <113578582+huamanraj@users.noreply.github.com>
Date: Wed, 19 Nov 2025 21:11:08 +0530
Subject: [PATCH 06/12] feat(newsletters): refactor tRPC client creation for
session authentication
---
.../src/app/api/newsletters/[slug]/route.ts | 5 +++--
apps/web/src/app/api/newsletters/route.ts | 7 ++++---
apps/web/src/lib/trpc-server.ts | 20 +++++++++++++++++++
3 files changed, 27 insertions(+), 5 deletions(-)
diff --git a/apps/web/src/app/api/newsletters/[slug]/route.ts b/apps/web/src/app/api/newsletters/[slug]/route.ts
index 998bc84c..cd65fa49 100644
--- a/apps/web/src/app/api/newsletters/[slug]/route.ts
+++ b/apps/web/src/app/api/newsletters/[slug]/route.ts
@@ -5,7 +5,7 @@ import matter from "gray-matter";
import { marked } from "marked";
import { getServerSession } from "next-auth";
import { authConfig } from "@/lib/auth/config";
-import { serverTrpc } from "@/lib/trpc-server";
+import { createAuthenticatedClient } from "@/lib/trpc-server";
// Configure marked for rich markdown support
marked.setOptions({
@@ -33,7 +33,8 @@ export async function GET(
// Verify paid subscription
try {
- const subscriptionStatus = await serverTrpc.user.subscriptionStatus.query();
+ const trpc = createAuthenticatedClient(session);
+ const subscriptionStatus = await (trpc.user as any).subscriptionStatus.query();
if (!subscriptionStatus.isPaidUser) {
return NextResponse.json(
diff --git a/apps/web/src/app/api/newsletters/route.ts b/apps/web/src/app/api/newsletters/route.ts
index 218a619a..22819131 100644
--- a/apps/web/src/app/api/newsletters/route.ts
+++ b/apps/web/src/app/api/newsletters/route.ts
@@ -4,12 +4,12 @@ import path from "path";
import matter from "gray-matter";
import { getServerSession } from "next-auth";
import { authConfig } from "@/lib/auth/config";
-import { serverTrpc } from "@/lib/trpc-server";
+import { createAuthenticatedClient } from "@/lib/trpc-server";
// Cache newsletters in memory for faster subsequent loads
let cachedNewsletters: any[] | null = null;
let lastCacheTime = 0;
-const CACHE_DURATION = 60000; // 1 minute cache
+const CACHE_DURATION = 600000; // 6 minute cache
export async function GET() {
// Authenticate user
@@ -24,7 +24,8 @@ export async function GET() {
// Verify paid subscription
try {
- const subscriptionStatus = await serverTrpc(session).user.subscriptionStatus.query();
+ const trpc = createAuthenticatedClient(session);
+ const subscriptionStatus = await (trpc.user as any).subscriptionStatus.query();
if (!subscriptionStatus.isPaidUser) {
return NextResponse.json(
diff --git a/apps/web/src/lib/trpc-server.ts b/apps/web/src/lib/trpc-server.ts
index 312bb362..1e9fc14c 100644
--- a/apps/web/src/lib/trpc-server.ts
+++ b/apps/web/src/lib/trpc-server.ts
@@ -1,6 +1,7 @@
import { createTRPCProxyClient, httpBatchLink } from "@trpc/client";
import superjson from "superjson";
import type { AppRouter } from "../../../api/src/routers/_app";
+import type { Session } from "next-auth";
/**
* Server-side tRPC client for use in NextAuth callbacks and server components
@@ -16,3 +17,22 @@ export const serverTrpc = createTRPCProxyClient({
}),
],
});
+
+/**
+ * Create a tRPC client with session authentication
+ */
+export function createAuthenticatedClient(session: Session) {
+ return createTRPCProxyClient({
+ links: [
+ httpBatchLink({
+ transformer: superjson,
+ url: `${process.env.NEXT_PUBLIC_API_URL || "http://localhost:4000"}/trpc`,
+ headers() {
+ return {
+ authorization: `Bearer ${session.user?.email}`, // Adjust based on your auth strategy
+ };
+ },
+ }),
+ ],
+ });
+}
From 70d59e7f9dce5765306149f9f5e5b0a7b590c748 Mon Sep 17 00:00:00 2001
From: Aman Raj <113578582+huamanraj@users.noreply.github.com>
Date: Fri, 21 Nov 2025 01:18:25 +0530
Subject: [PATCH 07/12] fix: auth errors in server side blog
---
apps/web/src/components/dashboard/Sidebar.tsx | 4 ----
apps/web/src/lib/trpc-server.ts | 10 +++++++---
2 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/apps/web/src/components/dashboard/Sidebar.tsx b/apps/web/src/components/dashboard/Sidebar.tsx
index 844e92b3..b23d958e 100644
--- a/apps/web/src/components/dashboard/Sidebar.tsx
+++ b/apps/web/src/components/dashboard/Sidebar.tsx
@@ -15,8 +15,6 @@ import {
StarIcon,
DocumentTextIcon,
Cog6ToothIcon,
- HeartIcon,
- EnvelopeIcon,
NewspaperIcon,
} from "@heroicons/react/24/outline";
import { useShowSidebar } from "@/store/useShowSidebar";
@@ -25,8 +23,6 @@ import { ProfilePic } from "./ProfilePic";
import { useSubscription } from "@/hooks/useSubscription";
import { OpensoxProBadge } from "../sheet/OpensoxProBadge";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
-import { useFilterStore } from "@/store/useFilterStore";
-import { Badge } from "@/components/ui/badge";
const SIDEBAR_ROUTES = [
{
diff --git a/apps/web/src/lib/trpc-server.ts b/apps/web/src/lib/trpc-server.ts
index 1e9fc14c..fb8cebbd 100644
--- a/apps/web/src/lib/trpc-server.ts
+++ b/apps/web/src/lib/trpc-server.ts
@@ -28,9 +28,13 @@ export function createAuthenticatedClient(session: Session) {
transformer: superjson,
url: `${process.env.NEXT_PUBLIC_API_URL || "http://localhost:4000"}/trpc`,
headers() {
- return {
- authorization: `Bearer ${session.user?.email}`, // Adjust based on your auth strategy
- };
+ const token = session.accessToken;
+ if (token) {
+ return {
+ authorization: `Bearer ${token}`,
+ };
+ }
+ return {};
},
}),
],
From da184145643810a3434ad0d7006809d904de008e Mon Sep 17 00:00:00 2001
From: Aman Raj <113578582+huamanraj@users.noreply.github.com>
Date: Fri, 21 Nov 2025 01:19:40 +0530
Subject: [PATCH 08/12] fix(newsletters): reduce cache duration for improved
performance
---
apps/web/src/app/api/newsletters/route.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/web/src/app/api/newsletters/route.ts b/apps/web/src/app/api/newsletters/route.ts
index 22819131..f9b00371 100644
--- a/apps/web/src/app/api/newsletters/route.ts
+++ b/apps/web/src/app/api/newsletters/route.ts
@@ -9,7 +9,7 @@ import { createAuthenticatedClient } from "@/lib/trpc-server";
// Cache newsletters in memory for faster subsequent loads
let cachedNewsletters: any[] | null = null;
let lastCacheTime = 0;
-const CACHE_DURATION = 600000; // 6 minute cache
+const CACHE_DURATION = 60000;
export async function GET() {
// Authenticate user
From 04a0c19c2736e8a20669c1bc6994efe8321ea556 Mon Sep 17 00:00:00 2001
From: apsinghdev
Date: Sat, 22 Nov 2025 14:02:03 +0530
Subject: [PATCH 09/12] ui: revamp the sidebar layout
---
apps/web/src/components/dashboard/Sidebar.tsx | 318 +++++++++++++++---
1 file changed, 277 insertions(+), 41 deletions(-)
diff --git a/apps/web/src/components/dashboard/Sidebar.tsx b/apps/web/src/components/dashboard/Sidebar.tsx
index b23d958e..6e545470 100644
--- a/apps/web/src/components/dashboard/Sidebar.tsx
+++ b/apps/web/src/components/dashboard/Sidebar.tsx
@@ -1,9 +1,9 @@
"use client";
-import React, { useState } from "react";
+import React, { useState, useEffect } from "react";
import Link from "next/link";
import SidebarItem from "../sidebar/SidebarItem";
-import { useRouter } from "next/navigation";
+import { useRouter, usePathname } from "next/navigation";
import { IconWrapper } from "../ui/IconWrapper";
import { motion, AnimatePresence } from "framer-motion";
import {
@@ -16,6 +16,9 @@ import {
DocumentTextIcon,
Cog6ToothIcon,
NewspaperIcon,
+ Squares2X2Icon,
+ ChevronDownIcon,
+ LockClosedIcon,
} from "@heroicons/react/24/outline";
import { useShowSidebar } from "@/store/useShowSidebar";
import { signOut, useSession } from "next-auth/react";
@@ -24,7 +27,15 @@ import { useSubscription } from "@/hooks/useSubscription";
import { OpensoxProBadge } from "../sheet/OpensoxProBadge";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
-const SIDEBAR_ROUTES = [
+type RouteConfig = {
+ path: string;
+ label: string;
+ icon: React.ReactNode;
+ badge?: string; // optional badge text (e.g., "New", "Beta")
+};
+
+// free features only
+const FREE_ROUTES: RouteConfig[] = [
{
path: "/dashboard/home",
label: "Home",
@@ -40,30 +51,55 @@ const SIDEBAR_ROUTES = [
label: "OSS Sheet",
icon: ,
},
+];
+
+// premium features under Opensox Pro
+const PREMIUM_ROUTES: RouteConfig[] = [
+ {
+ path: "/dashboard/pro/dashboard",
+ label: "Dashboard",
+ icon: ,
+ badge: "New",
+ },
{
path: "/dashboard/newsletters",
- label: "Newsletters",
+ label: "Newsletter",
icon: ,
- isPro: true,
+ badge: "New"
},
];
export default function Sidebar({ overlay = false }: { overlay?: boolean }) {
const { setShowSidebar, isCollapsed, toggleCollapsed } = useShowSidebar();
const router = useRouter();
+ const pathname = usePathname();
const { isPaidUser } = useSubscription();
+ const [proSectionExpanded, setProSectionExpanded] = useState(true);
+
+ // auto-expand pro section if user is on a premium route
+ useEffect(() => {
+ if (isPaidUser) {
+ const isOnPremiumRoute = PREMIUM_ROUTES.some((route) => {
+ return pathname === route.path || pathname.startsWith(`${route.path}/`);
+ });
+ if (isOnPremiumRoute) {
+ setProSectionExpanded(true);
+ }
+ }
+ }, [pathname, isPaidUser]);
const reqFeatureHandler = () => {
window.open("https://github.com/apsinghdev/opensox/issues", "_blank");
};
- const proClickHandler = () => {
+ const handleProSectionClick = () => {
if (isPaidUser) {
- router.push("/dashboard/pro/dashboard");
+ setProSectionExpanded(!proSectionExpanded);
} else {
router.push("/pricing");
}
};
+
const desktopWidth = isCollapsed ? 80 : 288;
const mobileWidth = desktopWidth;
@@ -118,20 +154,43 @@ export default function Sidebar({ overlay = false }: { overlay?: boolean }) {
- {SIDEBAR_ROUTES.map((route) => {
+ {/* free features section */}
+ {FREE_ROUTES.map((route) => {
+ const isActive =
+ pathname === route.path || pathname.startsWith(`${route.path}/`);
return (
-
-
+
+
{route.icon}
{!isCollapsed && (
-
-
+
+
{route.label}
- {route.isPro && (
-
+ {route.badge && (
+
+ {route.badge}
+
)}
)}
@@ -139,39 +198,216 @@ export default function Sidebar({ overlay = false }: { overlay?: boolean }) {
);
})}
-
-
-
-
+
+ {/* divider */}
+ {!isCollapsed && (
+
+ )}
+
+ {/* premium section */}
+ {!isCollapsed ? (
+
+ {(() => {
+ const isPremiumRouteActive = PREMIUM_ROUTES.some(
+ (route) =>
+ pathname === route.path ||
+ pathname.startsWith(`${route.path}/`)
+ );
+ const newFeaturesCount = PREMIUM_ROUTES.filter(
+ (route) => route.badge
+ ).length;
+ return (
+
{
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ handleProSectionClick();
+ }
+ }}
+ >
+
+
+
+
+
+
+ Opensox Pro
+
+
+ {newFeaturesCount > 0 && (
+
+ {newFeaturesCount}
+
+ )}
+
+
+ {isPaidUser && (
+
+ )}
+
+ );
+ })()}
+
+ {/* premium sub-items (only show if paid user and expanded) */}
+ {isPaidUser && proSectionExpanded && (
+
+ {PREMIUM_ROUTES.map((route) => {
+ const isActive =
+ pathname === route.path ||
+ pathname.startsWith(`${route.path}/`);
+ return (
+
+
+
+ {route.icon}
+
+
+
+ {route.label}
+
+ {route.badge && (
+
+ {route.badge}
+
+ )}
+
+
+
+ );
+ })}
+
+ )}
+
+ {/* free user: show locked preview */}
+ {!isPaidUser && (
+
+ {PREMIUM_ROUTES.map((route) => (
+ router.push("/pricing")}
+ className="w-full h-[44px] flex items-center rounded-md cursor-pointer transition-colors px-2 gap-3 opacity-50 hover:opacity-75 group"
+ role="button"
+ tabIndex={0}
+ aria-label={`${route.label} - Upgrade to Pro`}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ router.push("/pricing");
+ }
+ }}
+ >
+
+ {route.icon}
+
+
+
+ {route.label}
+
+ {route.badge && (
+
+ {route.badge}
+
+ )}
+
+
+
+
+
+ ))}
+
+ )}
+
+ ) : (
+ // collapsed sidebar: show icon only
+ {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ handleProSectionClick();
+ }
+ }}
+ >
+
+
+ )}
+
+ {/* divider */}
+ {!isCollapsed && (
+
+ )}
+
+ {/* utility features */}
}
collapsed={isCollapsed}
/>
- {!isCollapsed && !isPaidUser ? (
-
-
-
-
-
-
- Opensox Pro
-
-
-
-
- ) : (
- }
- collapsed={isCollapsed}
- />
- )}
{/* Bottom profile */}
From dc390f1436f2597896b8234507114da30d0d87e9 Mon Sep 17 00:00:00 2001
From: apsinghdev
Date: Sat, 22 Nov 2025 14:56:51 +0530
Subject: [PATCH 10/12] chore: ui fix
---
.../src/app/api/newsletters/[slug]/route.ts | 28 +++++++++++++------
apps/web/src/app/api/newsletters/route.ts | 26 ++++++++++-------
apps/web/src/components/dashboard/Sidebar.tsx | 8 +++---
3 files changed, 40 insertions(+), 22 deletions(-)
diff --git a/apps/web/src/app/api/newsletters/[slug]/route.ts b/apps/web/src/app/api/newsletters/[slug]/route.ts
index cd65fa49..fcc83698 100644
--- a/apps/web/src/app/api/newsletters/[slug]/route.ts
+++ b/apps/web/src/app/api/newsletters/[slug]/route.ts
@@ -9,13 +9,17 @@ import { createAuthenticatedClient } from "@/lib/trpc-server";
// Configure marked for rich markdown support
marked.setOptions({
- gfm: true, // GitHub Flavored Markdown: tables, task lists, etc.
- breaks: true, // Line breaks
+ 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
+// cache longer in production since newsletter content changes infrequently
+const CACHE_DURATION =
+ process.env.NODE_ENV === "production"
+ ? 3600000
+ : 60000;
export async function GET(
_request: Request,
@@ -23,7 +27,7 @@ export async function GET(
) {
// Authenticate user
const session = await getServerSession(authConfig);
-
+
if (!session || !session.user?.email) {
return NextResponse.json(
{ error: "Unauthorized - Please sign in" },
@@ -34,8 +38,10 @@ export async function GET(
// Verify paid subscription
try {
const trpc = createAuthenticatedClient(session);
- const subscriptionStatus = await (trpc.user as any).subscriptionStatus.query();
-
+ const subscriptionStatus = await (
+ trpc.user as any
+ ).subscriptionStatus.query();
+
if (!subscriptionStatus.isPaidUser) {
return NextResponse.json(
{ error: "Forbidden - Premium subscription required" },
@@ -63,7 +69,10 @@ export async function GET(
try {
if (!fs.existsSync(filePath)) {
- return NextResponse.json({ error: "Newsletter not found" }, { status: 404 });
+ return NextResponse.json(
+ { error: "Newsletter not found" },
+ { status: 404 }
+ );
}
const fileContent = fs.readFileSync(filePath, "utf8");
@@ -84,6 +93,9 @@ export async function GET(
return NextResponse.json(result);
} catch (error) {
console.error("Error reading newsletter:", error);
- return NextResponse.json({ error: "Newsletter not found" }, { status: 404 });
+ 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
index f9b00371..11398e72 100644
--- a/apps/web/src/app/api/newsletters/route.ts
+++ b/apps/web/src/app/api/newsletters/route.ts
@@ -9,12 +9,16 @@ import { createAuthenticatedClient } from "@/lib/trpc-server";
// Cache newsletters in memory for faster subsequent loads
let cachedNewsletters: any[] | null = null;
let lastCacheTime = 0;
-const CACHE_DURATION = 60000;
+// cache longer in production since newsletter content changes infrequently
+const CACHE_DURATION =
+ process.env.NODE_ENV === "production"
+ ? 3600000 // 1 hour in production
+ : 60000; // 1 minute in dev
export async function GET() {
// Authenticate user
const session = await getServerSession(authConfig);
-
+
if (!session || !session.user?.email) {
return NextResponse.json(
{ error: "Unauthorized - Please sign in" },
@@ -25,8 +29,10 @@ export async function GET() {
// Verify paid subscription
try {
const trpc = createAuthenticatedClient(session);
- const subscriptionStatus = await (trpc.user as any).subscriptionStatus.query();
-
+ const subscriptionStatus = await (
+ trpc.user as any
+ ).subscriptionStatus.query();
+
if (!subscriptionStatus.isPaidUser) {
return NextResponse.json(
{ error: "Forbidden - Premium subscription required" },
@@ -42,14 +48,14 @@ 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 });
@@ -57,14 +63,14 @@ export async function GET() {
}
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 {
id: file.replace(".md", ""),
title: data.title || "Untitled",
@@ -75,11 +81,11 @@ export async function GET() {
};
})
.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);
diff --git a/apps/web/src/components/dashboard/Sidebar.tsx b/apps/web/src/components/dashboard/Sidebar.tsx
index 6e545470..4257a398 100644
--- a/apps/web/src/components/dashboard/Sidebar.tsx
+++ b/apps/web/src/components/dashboard/Sidebar.tsx
@@ -65,7 +65,7 @@ const PREMIUM_ROUTES: RouteConfig[] = [
path: "/dashboard/newsletters",
label: "Newsletter",
icon: ,
- badge: "New"
+ badge: "New",
},
];
@@ -188,7 +188,7 @@ export default function Sidebar({ overlay = false }: { overlay?: boolean }) {
{route.label}
{route.badge && (
-
+
{route.badge}
)}
@@ -318,7 +318,7 @@ export default function Sidebar({ overlay = false }: { overlay?: boolean }) {
{route.label}
{route.badge && (
-
+
{route.badge}
)}
@@ -362,7 +362,7 @@ export default function Sidebar({ overlay = false }: { overlay?: boolean }) {
{route.label}
{route.badge && (
-
+
{route.badge}
)}
From 79af1ea8ee0eb385b193605a58a630786f1c9dd9 Mon Sep 17 00:00:00 2001
From: apsinghdev
Date: Sun, 23 Nov 2025 22:48:57 +0530
Subject: [PATCH 11/12] chore: add premium newsletter content and submodule
setup
---
.gitmodules | 3 +
apps/web/.gitignore | 2 +
apps/web/package.json | 2 +-
apps/web/scripts/init-submodules.sh | 16 ++
.../src/app/api/newsletters/[slug]/route.ts | 11 +-
apps/web/src/app/api/newsletters/route.ts | 6 +-
apps/web/src/content/newsletters-premium | 1 +
.../content/newsletters/2024-01-welcome.md | 16 ++
.../newsletters/2024-02-new-features.md | 165 ------------------
.../newsletters/2024-03-success-stories.md | 58 ------
10 files changed, 50 insertions(+), 230 deletions(-)
create mode 100644 .gitmodules
create mode 100755 apps/web/scripts/init-submodules.sh
create mode 160000 apps/web/src/content/newsletters-premium
delete mode 100644 apps/web/src/content/newsletters/2024-02-new-features.md
delete mode 100644 apps/web/src/content/newsletters/2024-03-success-stories.md
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 00000000..d2230743
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "apps/web/src/content/newsletters-premium"]
+ path = apps/web/src/content/newsletters-premium
+ url = git@github.com:apsinghdev/opensox-newsletters-premium.git
diff --git a/apps/web/.gitignore b/apps/web/.gitignore
index 26b002aa..90975c82 100644
--- a/apps/web/.gitignore
+++ b/apps/web/.gitignore
@@ -38,3 +38,5 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
+
+src/content/newsletters-premium/
\ No newline at end of file
diff --git a/apps/web/package.json b/apps/web/package.json
index 0c34bea7..06ca9e96 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -4,7 +4,7 @@
"private": true,
"scripts": {
"dev": "next dev",
- "build": "next build",
+ "build": "bash ./scripts/init-submodules.sh && next build",
"start": "next start",
"lint": "next lint"
},
diff --git a/apps/web/scripts/init-submodules.sh b/apps/web/scripts/init-submodules.sh
new file mode 100755
index 00000000..660fc12f
--- /dev/null
+++ b/apps/web/scripts/init-submodules.sh
@@ -0,0 +1,16 @@
+#!/bin/bash
+# initialize git submodules during vercel build
+
+# setup ssh for private submodule
+if [ -n "$GIT_SSH_KEY" ]; then
+ mkdir -p ~/.ssh
+ echo "$GIT_SSH_KEY" > ~/.ssh/id_ed25519
+ chmod 600 ~/.ssh/id_ed25519
+ ssh-keyscan github.com >> ~/.ssh/known_hosts
+fi
+
+# initialize and update submodules
+git submodule update --init --recursive --remote
+
+echo "submodules initialized successfully"
+
diff --git a/apps/web/src/app/api/newsletters/[slug]/route.ts b/apps/web/src/app/api/newsletters/[slug]/route.ts
index fcc83698..da59db68 100644
--- a/apps/web/src/app/api/newsletters/[slug]/route.ts
+++ b/apps/web/src/app/api/newsletters/[slug]/route.ts
@@ -16,10 +16,7 @@ marked.setOptions({
// Cache individual newsletters
const newsletterCache = new Map();
// cache longer in production since newsletter content changes infrequently
-const CACHE_DURATION =
- process.env.NODE_ENV === "production"
- ? 3600000
- : 60000;
+const CACHE_DURATION = process.env.NODE_ENV === "production" ? 3600000 : 60000;
export async function GET(
_request: Request,
@@ -64,7 +61,11 @@ export async function GET(
return NextResponse.json(cached.data);
}
- const newslettersDir = path.join(process.cwd(), "src/content/newsletters");
+ // read from premium directory for paid users
+ const newslettersDir = path.join(
+ process.cwd(),
+ "apps/web/src/content/newsletters-premium"
+ );
const filePath = path.join(newslettersDir, `${slug}.md`);
try {
diff --git a/apps/web/src/app/api/newsletters/route.ts b/apps/web/src/app/api/newsletters/route.ts
index 11398e72..c7bb852a 100644
--- a/apps/web/src/app/api/newsletters/route.ts
+++ b/apps/web/src/app/api/newsletters/route.ts
@@ -54,7 +54,11 @@ export async function GET() {
return NextResponse.json(cachedNewsletters);
}
- const newslettersDir = path.join(process.cwd(), "src/content/newsletters");
+ // read from premium directory for paid users
+ const newslettersDir = path.join(
+ process.cwd(),
+ "apps/web/src/content/newsletters-premium"
+ );
try {
if (!fs.existsSync(newslettersDir)) {
diff --git a/apps/web/src/content/newsletters-premium b/apps/web/src/content/newsletters-premium
new file mode 160000
index 00000000..0f49f26e
--- /dev/null
+++ b/apps/web/src/content/newsletters-premium
@@ -0,0 +1 @@
+Subproject commit 0f49f26e2a0c07d9765786b0d44754cc586e234f
diff --git a/apps/web/src/content/newsletters/2024-01-welcome.md b/apps/web/src/content/newsletters/2024-01-welcome.md
index 0474c1c4..a19c8b79 100644
--- a/apps/web/src/content/newsletters/2024-01-welcome.md
+++ b/apps/web/src/content/newsletters/2024-01-welcome.md
@@ -27,5 +27,21 @@ We're thrilled to have you here! **Opensox AI** is designed to transform how dev
Connect with thousands of developers on our [Discord server](https://discord.gg/37ke8rYnRM) and share your journey.
+---
+
+## Want More?
+
+This is a free sample newsletter. **Premium subscribers** get exclusive access to:
+
+- 📰 **Weekly Curated Insights** on open-source jobs and opportunities
+- 💰 **Funding News** and investment trends in the OSS ecosystem
+- 🚀 **Trending Projects** before they go viral
+- 💡 **Expert Tips** from maintainers and core contributors
+- 📊 **Industry Analysis** and deep dives into OSS trends
+
+[**Upgrade to Premium**](/pricing) to unlock the full newsletter archive and stay ahead in the open-source world.
+
+---
+
**Happy Contributing!**
The Opensox Team
diff --git a/apps/web/src/content/newsletters/2024-02-new-features.md b/apps/web/src/content/newsletters/2024-02-new-features.md
deleted file mode 100644
index a184b65f..00000000
--- a/apps/web/src/content/newsletters/2024-02-new-features.md
+++ /dev/null
@@ -1,165 +0,0 @@
----
-title: "opensox newsletter demo - full formatting test"
-date: "2025-06-10"
-excerpt: "a stress test markdown file to validate rendering, spacing, images, lists, code, tables, and odd content."
-readTime: "7 min read"
----
-
-# full formatting test
-
-welcome to the **opensox formatting demo**.
-this file tests every tricky markdown case that your newsletter system might hit.
-
-## headings
-
-### h3 heading
-#### h4 heading
-##### h5 heading
-
----
-
-## paragraphs and breaks
-
-this is a normal paragraph to test spacing and line height.
-
-here is another paragraph to see if margins are correct.
-
-this is a line with
-a manual line break
-to test ` ` handling.
-
----
-
-## bold, italics, links
-
-this is **bold text**.
-this is *italic text*.
-this is **bold and *nested italic***.
-this is a [link to opensox](https://opensox.ai).
-
----
-
-## images
-
-
-
-
-
-both of these should render cleanly.
-
----
-
-## lists
-
-### unordered list
-
-- item one
-- item two
- - sub item a
- - sub item b
- - deep sub item
-
-### ordered list
-
-1. first
-2. second
-3. third
- 1. nested a
- 2. nested b
-
----
-
-## blockquote
-
-> this is a quote block.
-> it should have left padding and a border.
-
----
-
-## code blocks
-
-### inline code
-
-here is some inline code: `npm install opensox`.
-
-### fenced code
-
-```ts
-export function example() {
- console.log("hello from opensox");
- return { ok: true };
-}
-````
-
-### long code block
-
-```json
-{
- "project": "opensox-ai",
- "features": [
- "ai search",
- "project tags",
- "user onboarding"
- ],
- "version": "1.0.0"
-}
-```
-
----
-
-## table
-
-| feature | status | notes |
-| ----------- | -------- | ------------------------- |
-| ai matching | live | powered by smart scoring |
-| onboarding | improved | new user flows added |
-| news feed | coming | planned for march release |
-
----
-
-## inline html test
-
-
-this is inline html.
-your renderer should not break when it sees light html.
-
-
----
-
-## horizontal rules
-
----
-
-another section after hr.
-
----
-
-## weird characters test
-
-quotes: "hello", 'hi'
-symbols: © ® ™ ∞ ≈ ± ÷
-punctuation: … · • ° ¶ §
-
-(no em-dashes used, only hyphens)
-
----
-
-## long paragraph stress test
-
-this is a deliberately long paragraph that exists only to test line wrapping, max width constraints, and readability under a large continuous block of text without breaks. your ui should not collapse, overflow horizontally, or produce awkward spacing when the text becomes extremely long. this type of paragraph commonly appears in newsletter intros, community messages, and deep write ups. verifying its behavior now will save you from unexpected layout issues later in production environments where user generated content appears.
-
----
-
-## links with titles
-
-[opensox homepage](https://opensox.ai "opensox official site")
-
----
-
-## ending message
-
-thanks for reading this giant formatting demo.
-you can delete it after testing.
-
-**the opensox team**
-
diff --git a/apps/web/src/content/newsletters/2024-03-success-stories.md b/apps/web/src/content/newsletters/2024-03-success-stories.md
deleted file mode 100644
index df85a86e..00000000
--- a/apps/web/src/content/newsletters/2024-03-success-stories.md
+++ /dev/null
@@ -1,58 +0,0 @@
----
-title: "Community Success Stories: March Edition"
-date: "2025-11-08"
-excerpt: "Hear inspiring stories from developers who landed their dream jobs through open-source contributions made via Opensox AI."
-readTime: "5 min read"
----
-
-# Success Stories That Inspire Us
-
-This month, we're celebrating the **amazing achievements** of our community members who transformed their careers through open-source.
-
-## Featured Story: From Contributor to Maintainer
-
-**Alex Thompson** started contributing to a React UI library through Opensox AI last year. Today, Alex is a core maintainer of the project with over 200 contributions.
-
-> "Opensox helped me find projects that matched my skill level perfectly. The journey from first PR to maintainer was incredible!" - Alex
-
-## By The Numbers
-
-- **12,000+** projects matched
-- **8,500+** successful contributions
-- **250+** developers hired
-- **95%** satisfaction rate
-
-## Tips for Success
-
-### 1. Start Small
-Begin with "good first issue" tags to build confidence.
-
-### 2. Be Consistent
-Regular contributions matter more than large ones.
-
-### 3. Engage with Community
-Ask questions, help others, and build relationships.
-
-## Upcoming Events
-
-📅 **Open Source Workshop** - March 20th
-Learn best practices for contributing to large projects.
-
-📅 **Community Meetup** - March 28th
-Network with fellow contributors (virtual).
-
-## Premium Launch
-
-We're excited to announce **Opensox Premium** launching next month!
-
-Benefits include:
-- Priority project matching
-- Advanced analytics dashboard
-- 1-on-1 mentorship sessions
-- Early access to new features
-
----
-
-**Keep contributing, keep growing!**
-
-[Share your story](mailto:hi@opensox.ai) | [Join Discord](https://discord.gg/37ke8rYnRM) | [Follow on X](https://x.com/ajeetunc)
From 80086d5850e797a700e225b5b3698d3807bead3b Mon Sep 17 00:00:00 2001
From: apsinghdev
Date: Mon, 24 Nov 2025 15:40:16 +0530
Subject: [PATCH 12/12] fix: add error handling and mitigate SSH key exposure
risk
---
apps/web/next.config.js | 1 -
apps/web/scripts/init-submodules.sh | 10 ++++++----
2 files changed, 6 insertions(+), 5 deletions(-)
diff --git a/apps/web/next.config.js b/apps/web/next.config.js
index e1cb010e..dc6d8137 100644
--- a/apps/web/next.config.js
+++ b/apps/web/next.config.js
@@ -19,7 +19,6 @@ const nextConfig = {
experimental: {
optimizePackageImports: ['lucide-react', '@heroicons/react'],
},
- swcMinify: true,
};
module.exports = nextConfig;
\ No newline at end of file
diff --git a/apps/web/scripts/init-submodules.sh b/apps/web/scripts/init-submodules.sh
index 660fc12f..3608f0c0 100755
--- a/apps/web/scripts/init-submodules.sh
+++ b/apps/web/scripts/init-submodules.sh
@@ -1,12 +1,14 @@
#!/bin/bash
# initialize git submodules during vercel build
+set -e # Exit immediately on any error
+
# setup ssh for private submodule
if [ -n "$GIT_SSH_KEY" ]; then
- mkdir -p ~/.ssh
- echo "$GIT_SSH_KEY" > ~/.ssh/id_ed25519
- chmod 600 ~/.ssh/id_ed25519
- ssh-keyscan github.com >> ~/.ssh/known_hosts
+ mkdir -p ~/.ssh || { echo "Failed to create ~/.ssh directory" >&2; exit 1; }
+ printf '%s' "$GIT_SSH_KEY" > ~/.ssh/id_ed25519 || { echo "Failed to write SSH key" >&2; exit 1; }
+ chmod 600 ~/.ssh/id_ed25519 || { echo "Failed to set SSH key permissions" >&2; exit 1; }
+ ssh-keyscan -t ed25519 github.com >> ~/.ssh/known_hosts 2>/dev/null || true
fi
# initialize and update submodules