Skip to content

Commit 56cf603

Browse files
committed
[bounty-to-hire]: create ui for newsletter
1 parent 5445fd9 commit 56cf603

File tree

13 files changed

+2205
-52
lines changed

13 files changed

+2205
-52
lines changed

apps/api/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,14 @@
1919
"@types/express": "^4.17.21",
2020
"@types/jsonwebtoken": "^9.0.7",
2121
"@types/node": "^24.5.1",
22-
"prisma": "^5.22.0",
22+
"prisma": "^6.19.0",
2323
"tsx": "^4.20.3",
2424
"typescript": "^5.9.2"
2525
},
2626
"dependencies": {
2727
"@octokit/graphql": "^9.0.1",
2828
"@opensox/shared": "workspace:*",
29-
"@prisma/client": "^5.22.0",
29+
"@prisma/client": "^6.19.0",
3030
"@trpc/server": "^11.5.1",
3131
"cors": "^2.8.5",
3232
"dotenv": "^16.5.0",

apps/web/next.config.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,13 @@ const nextConfig = {
66
protocol: "https",
77
hostname: "avatars.githubusercontent.com",
88
},
9+
{
10+
protocol: "https",
11+
hostname: "images.pexels.com",
12+
pathname: "/**", // optional but recommended
13+
},
914
],
1015
},
1116
};
1217

13-
module.exports = nextConfig;
18+
module.exports = nextConfig;

apps/web/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,10 @@
3333
"posthog-js": "^1.203.1",
3434
"react": "^18.2.0",
3535
"react-dom": "^18.2.0",
36+
"react-markdown": "^10.1.0",
3637
"react-qr-code": "^2.0.18",
3738
"react-tweet": "^3.2.1",
39+
"remark-gfm": "^4.0.1",
3840
"superjson": "^2.2.5",
3941
"tailwind-merge": "^2.5.4",
4042
"tailwindcss-animate": "^1.0.7",
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
"use client";
2+
3+
import { NEWSLETTERS } from "@/data/newsletters";
4+
import NewsletterContent from "@/components/newsletters/NewsletterContent";
5+
import { CalendarIcon, ArrowLeftIcon, ClockIcon, BookmarkIcon } from "@heroicons/react/24/outline";
6+
import { BookmarkIcon as BookmarkSolid } from "@heroicons/react/24/solid";
7+
import Link from "next/link";
8+
import { useState } from "react";
9+
10+
export default function Page({ params }: { params: { slug: string } }) {
11+
const n = NEWSLETTERS.find((x) => x.slug === params.slug);
12+
const [isSaved, setIsSaved] = useState(false);
13+
14+
if (!n) {
15+
return (
16+
<div className="w-full min-h-screen bg-black flex items-center justify-center">
17+
<div className="text-center">
18+
<h1 className="text-xl font-light text-white mb-8">Newsletter not found</h1>
19+
<Link
20+
href="/dashboard/newsletters"
21+
className="inline-flex items-center gap-2 text-zinc-400 hover:text-white transition-colors text-sm font-light"
22+
>
23+
<ArrowLeftIcon className="w-3 h-3" />
24+
Back to newsletters
25+
</Link>
26+
</div>
27+
</div>
28+
);
29+
}
30+
31+
const wordCount = n.body.split(/\s+/).length;
32+
const readingTime = Math.ceil(wordCount / 200);
33+
34+
return (
35+
<main className="min-h-screen bg-black">
36+
37+
{/* Back Navigation */}
38+
<div className="border-b border-zinc-900 sticky top-0 bg-black/80 backdrop-blur-xl z-40">
39+
<div className="max-w-3xl mx-auto px-6 py-6">
40+
<Link
41+
href="/dashboard/newsletters"
42+
className="inline-flex items-center gap-3 text-zinc-500 hover:text-white transition-colors text-sm font-light"
43+
>
44+
<ArrowLeftIcon className="w-3 h-3" />
45+
Newsletters
46+
</Link>
47+
</div>
48+
</div>
49+
50+
{/* Article Content */}
51+
<article className="max-w-3xl mx-auto px-6 py-16">
52+
53+
{/* Article Header */}
54+
<header className="mb-20">
55+
{n.featured && (
56+
<div className="mb-8">
57+
<span className="text-xs tracking-[0.2em] uppercase text-zinc-600 font-light">
58+
Featured
59+
</span>
60+
</div>
61+
)}
62+
63+
<h1 className="text-4xl lg:text-5xl font-light tracking-tight text-white mb-8 leading-[1.1]">
64+
{n.title}
65+
</h1>
66+
67+
<p className="text-lg text-zinc-400 mb-12 leading-relaxed font-light">
68+
{n.excerpt}
69+
</p>
70+
71+
{/* Meta Information */}
72+
<div className="flex items-center justify-between py-6 border-y border-zinc-900">
73+
<div className="flex items-center gap-8 text-zinc-500 text-sm font-light">
74+
<div className="flex items-center gap-2">
75+
<CalendarIcon className="w-3.5 h-3.5" />
76+
<span>{new Date(n.date).toLocaleDateString('en-US', {
77+
year: 'numeric',
78+
month: 'short',
79+
day: 'numeric'
80+
})}</span>
81+
</div>
82+
83+
<div className="flex items-center gap-2">
84+
<ClockIcon className="w-3.5 h-3.5" />
85+
<span>{readingTime} min</span>
86+
</div>
87+
</div>
88+
89+
{/* Save Button */}
90+
<button
91+
onClick={() => setIsSaved(!isSaved)}
92+
className={`
93+
flex items-center gap-2 transition-all duration-300 text-sm font-light
94+
${isSaved
95+
? 'text-white'
96+
: 'text-zinc-500 hover:text-white'
97+
}
98+
`}
99+
>
100+
{isSaved ? (
101+
<BookmarkSolid className="w-4 h-4" />
102+
) : (
103+
<BookmarkIcon className="w-4 h-4" />
104+
)}
105+
<span>{isSaved ? 'Saved' : 'Save'}</span>
106+
</button>
107+
</div>
108+
</header>
109+
110+
{/* Article Body */}
111+
<div className="prose prose-invert max-w-none">
112+
<div className="text-zinc-300 leading-[1.8] text-base font-light">
113+
<NewsletterContent body={n.body} />
114+
</div>
115+
</div>
116+
117+
{/* Article Footer */}
118+
<footer className="mt-32 pt-12 border-t border-zinc-900">
119+
<div className="flex items-center justify-between">
120+
<div className="text-zinc-600 text-sm font-light">
121+
OpenSox Team
122+
</div>
123+
124+
<Link
125+
href="/dashboard/newsletters"
126+
className="text-zinc-400 hover:text-white text-sm font-light transition-colors"
127+
>
128+
More newsletters →
129+
</Link>
130+
</div>
131+
</footer>
132+
</article>
133+
</main>
134+
);
135+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import NewsletterContainer from '@/components/newsletters/NewsletterContainer'
2+
3+
export default function Page() {
4+
return (
5+
<div className="min-h-screen bg-[#101010]">
6+
<NewsletterContainer />
7+
</div>
8+
)
9+
}

apps/web/src/components/dashboard/Sidebar.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
StarIcon,
1818
HeartIcon,
1919
EnvelopeIcon,
20+
NewspaperIcon,
2021
} from "@heroicons/react/24/outline";
2122
import { useShowSidebar } from "@/store/useShowSidebar";
2223
import { signOut } from "next-auth/react";
@@ -138,6 +139,16 @@ export default function Sidebar() {
138139
icon={<SparklesIcon className="size-5" />}
139140
collapsed={isCollapsed}
140141
/>
142+
<Link
143+
href="newsletters"
144+
className={getSidebarLinkClassName(pathname, "/newsletters")}
145+
>
146+
<SidebarItem
147+
itemName="Newsletters"
148+
icon={<NewspaperIcon className="size-5" />}
149+
collapsed={isCollapsed}
150+
/>
151+
</Link>
141152
<SidebarItem
142153
itemName="Opensox premium"
143154
onclick={premiumClickHandler}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
"use client";
2+
3+
import { useState, useMemo } from "react";
4+
import NewsletterList from "./NewsletterList";
5+
import { MagnifyingGlassIcon, CalendarIcon, ChevronDownIcon } from "@heroicons/react/24/outline";
6+
7+
export default function NewsletterContainer() {
8+
const [q, setQ] = useState("");
9+
const [selectedMonth, setSelectedMonth] = useState<string>("all");
10+
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
11+
12+
const monthFilters = useMemo(() => {
13+
const months = [
14+
{ value: "all", label: "All months" },
15+
{ value: "2025-11", label: "November 2025" },
16+
{ value: "2025-10", label: "October 2025" },
17+
{ value: "2025-09", label: "September 2025" },
18+
];
19+
return months;
20+
}, []);
21+
22+
const selectedFilterLabel = monthFilters.find(filter => filter.value === selectedMonth)?.label || "All months";
23+
24+
return (
25+
<div className="w-full min-h-screen bg-black">
26+
<div className="max-w-5xl mx-auto px-6 py-20">
27+
28+
{/* Header */}
29+
<div className="mb-4">
30+
<div className="inline-block mb-6">
31+
<span className="text-xs tracking-[0.2em] uppercase text-zinc-500 font-light">
32+
Pro Access
33+
</span>
34+
</div>
35+
36+
<h1 className="text-6xl lg:text-7xl font-light tracking-tight text-white mb-8 leading-[0.95]">
37+
Newsletters
38+
</h1>
39+
40+
<p className="text-lg text-zinc-400 max-w-xl font-light leading-relaxed">
41+
Curated insights and platform updates
42+
</p>
43+
</div>
44+
45+
{/* Controls */}
46+
<div className="flex flex-col lg:flex-row gap-3 mb-16">
47+
48+
{/* Search */}
49+
<div className="relative flex-1">
50+
<MagnifyingGlassIcon className="absolute left-0 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-600" />
51+
<input
52+
value={q}
53+
onChange={(e) => setQ(e.target.value)}
54+
placeholder="Search"
55+
className="w-full pl-7 pr-4 py-3 bg-transparent border-b border-zinc-800 text-white placeholder:text-zinc-600 focus:outline-none focus:border-zinc-600 transition-colors text-sm font-light"
56+
/>
57+
</div>
58+
59+
{/* Filter */}
60+
<div className="relative lg:w-48">
61+
<button
62+
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
63+
className="w-full flex items-center justify-between pl-7 pr-4 py-3 bg-transparent border-b border-zinc-800 text-white hover:border-zinc-600 transition-colors group"
64+
>
65+
<CalendarIcon className="absolute left-0 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-600 group-hover:text-zinc-400 transition-colors" />
66+
<span className="text-sm font-light">{selectedFilterLabel}</span>
67+
<ChevronDownIcon
68+
className={`w-3 h-3 text-zinc-600 transition-all duration-300 ${
69+
isDropdownOpen ? 'rotate-180' : ''
70+
}`}
71+
/>
72+
</button>
73+
74+
{isDropdownOpen && (
75+
<>
76+
<div
77+
className="fixed inset-0 z-10"
78+
onClick={() => setIsDropdownOpen(false)}
79+
/>
80+
81+
<div className="absolute top-full right-0 mt-3 w-64 bg-zinc-950 border border-zinc-800 z-20 overflow-hidden">
82+
{monthFilters.map((filter) => (
83+
<button
84+
key={filter.value}
85+
onClick={() => {
86+
setSelectedMonth(filter.value);
87+
setIsDropdownOpen(false);
88+
}}
89+
className={`w-full text-left px-6 py-4 text-sm transition-all font-light ${
90+
selectedMonth === filter.value
91+
? 'text-white bg-zinc-900'
92+
: 'text-zinc-400 hover:text-white hover:bg-zinc-900/50'
93+
}`}
94+
>
95+
{filter.label}
96+
</button>
97+
))}
98+
</div>
99+
</>
100+
)}
101+
</div>
102+
</div>
103+
104+
{/* Active Filter */}
105+
{selectedMonth !== "all" && (
106+
<div className="flex items-center gap-4 mb-12 pb-12 border-b border-zinc-900">
107+
<span className="text-xs text-zinc-600 font-light tracking-wider">FILTERED BY</span>
108+
<button
109+
onClick={() => setSelectedMonth("all")}
110+
className="text-sm text-zinc-400 hover:text-white transition-colors font-light"
111+
>
112+
{selectedFilterLabel} ×
113+
</button>
114+
</div>
115+
)}
116+
117+
{/* Newsletter List */}
118+
<NewsletterList query={q} monthFilter={selectedMonth} />
119+
120+
{/* Footer */}
121+
<div className="text-center pt-12 mt-12 border-t border-zinc-900">
122+
<p className="text-sm text-zinc-600 font-light tracking-wide">
123+
More insights coming soon
124+
</p>
125+
</div>
126+
</div>
127+
</div>
128+
);
129+
}

0 commit comments

Comments
 (0)