-
Notifications
You must be signed in to change notification settings - Fork 119
Implement Newsletter dashboard #160
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
dakshydv
wants to merge
2
commits into
apsinghdev:main
Choose a base branch
from
dakshydv:fix/newsletter-dashboard
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
314 changes: 314 additions & 0 deletions
314
apps/web/src/app/(main)/dashboard/newsletter/[id]/page.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,314 @@ | ||
| import { Button } from "@/components/ui/button"; | ||
| import { getNewsletterById } from "@/lib/newsletters"; | ||
| import { ChevronLeft } from "lucide-react"; | ||
| import Link from "next/link"; | ||
|
|
||
| type params = { | ||
| params: { id: string }; | ||
| }; | ||
|
|
||
| function renderInline(text: string) { | ||
| // inline tags with backticks like `good first issue`, bold **text** and links [text](url) | ||
| const parts = text.split(/(`[^`]+`)/g).filter(Boolean); | ||
| return parts.flatMap((part, idx) => { | ||
| // backtick tag -> render as pill/tag | ||
| if (/^`[^`]+`$/.test(part)) { | ||
| const inner = part.slice(1, -1); | ||
| return ( | ||
| <span | ||
| key={`tag-${idx}`} | ||
| className="inline-flex items-center bg-ox-purple/10 text-ox-purple px-2 py-0.5 rounded-full text-xs font-medium" | ||
| > | ||
| {inner} | ||
| </span> | ||
| ); | ||
| } | ||
|
|
||
| // handle links inside non-backtick segments | ||
| const boldParts = part.split(/(\*\*[^*]+\*\*)/g).filter(Boolean); | ||
| return boldParts.map((bp, j) => { | ||
| if (/^\*\*/.test(bp)) { | ||
| const inner = bp.replace(/\*\*/g, ""); | ||
| return ( | ||
| <strong key={`b-${idx}-${j}`} className="text-ox-white font-semibold"> | ||
| {inner} | ||
| </strong> | ||
| ); | ||
| } | ||
|
|
||
| // handle multiple links in the segment | ||
| const nodes: any[] = []; | ||
| const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g; | ||
| let lastIndex = 0; | ||
| let m: RegExpExecArray | null; | ||
| while ((m = linkRegex.exec(bp)) !== null) { | ||
| const [matchText, t, url] = m; | ||
| const index = m.index; | ||
| if (index > lastIndex) nodes.push(bp.slice(lastIndex, index)); | ||
| nodes.push( | ||
| <a | ||
| key={`a-${idx}-${j}-${index}`} | ||
| href={url} | ||
| className="text-ox-purple underline" | ||
| target="_blank" | ||
| rel="noreferrer" | ||
| > | ||
| {t} | ||
| </a> | ||
| ); | ||
| lastIndex = index + matchText.length; | ||
| } | ||
| if (lastIndex < bp.length) nodes.push(bp.slice(lastIndex)); | ||
|
|
||
| return ( | ||
| <span key={`s-${idx}-${j}`}> | ||
| {nodes.map((n, k) => | ||
| typeof n === "string" ? <span key={k}>{n}</span> : n | ||
| )} | ||
| </span> | ||
| ); | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| function renderMarkdown(md: string) { | ||
| const lines = md.split("\n"); | ||
| const nodes: any[] = []; | ||
| let listBuffer: string[] = []; | ||
| let blockquoteBuffer: string[] = []; | ||
|
|
||
| function flushList() { | ||
| if (listBuffer.length) { | ||
| nodes.push( | ||
| <ul | ||
| key={`ul-${nodes.length}`} | ||
| className="list-disc pl-6 space-y-1 text-ox-white" | ||
| > | ||
| {listBuffer.map((li, i) => ( | ||
| <li key={i}>{renderInline(li.replace(/^-\s*/, ""))}</li> | ||
| ))} | ||
| </ul> | ||
| ); | ||
| listBuffer = []; | ||
| } | ||
| } | ||
|
|
||
| lines.forEach((raw, idx) => { | ||
| if (raw === "__CONSUMED__") return; | ||
| const line = raw.trim(); | ||
| // handle code blocks | ||
| if (line.startsWith("```")) { | ||
| flushList(); | ||
| const lang = line.slice(3).trim(); | ||
| const codeLines: string[] = []; | ||
| let j = idx + 1; | ||
| while (j < lines.length && !lines[j].trim().startsWith("```")) { | ||
| codeLines.push(lines[j]); | ||
| j++; | ||
| } | ||
| const codeContent = codeLines.join("\n"); | ||
| if (lang === "embed") { | ||
| // embed block expects a URL in the content | ||
| const src = codeContent.trim(); | ||
| try { | ||
| const url = new URL(src); | ||
| if (!["http:", "https:"].includes(url.protocol)) { | ||
| console.warn("Invalid embed URL protocol:", src); | ||
| return; // skip this embed | ||
| } | ||
| } catch { | ||
| console.warn("Invalid embed URL:", src); | ||
| } | ||
| nodes.push( | ||
| <div key={`embed-${idx}`} className="my-4 w-full"> | ||
| <div className="relative" style={{ paddingTop: "56.25%" }}> | ||
| <iframe | ||
| src={src} | ||
| title={src} | ||
| className="absolute top-0 left-0 w-full h-full rounded-md" | ||
| frameBorder="0" | ||
| allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" | ||
| allowFullScreen | ||
| /> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } else { | ||
| nodes.push( | ||
| <pre | ||
| key={`code-${idx}`} | ||
| className="my-4 overflow-auto rounded-md bg-[#0b0b0c] p-3 text-sm" | ||
| > | ||
| <code>{codeContent}</code> | ||
| </pre> | ||
| ); | ||
| } | ||
| // replace consumed lines with empties so outer loop sees them as blank and skips | ||
| for (let k = idx; k <= j; k++) { | ||
| lines[k] = "__CONSUMED__"; | ||
| } | ||
| return; | ||
| } | ||
| if (!line) { | ||
| flushList(); | ||
| if (blockquoteBuffer.length) { | ||
| nodes.push( | ||
| <blockquote | ||
| key={`bq-${idx}`} | ||
| className="border-l-4 pl-4 italic text-ox-white/80 my-3" | ||
| > | ||
| {blockquoteBuffer.map((l, i) => ( | ||
| <p key={i} className="m-0 py-1"> | ||
| {renderInline(l.replace(/^>\s?/, ""))} | ||
| </p> | ||
| ))} | ||
| </blockquote> | ||
| ); | ||
| blockquoteBuffer = []; | ||
| } | ||
| return nodes.push(<p key={idx} className="py-2" />); | ||
| } | ||
| // heading 1 | ||
| if (line.startsWith("# ")) { | ||
| flushList(); | ||
| nodes.push( | ||
| <h1 key={idx} className="text-4xl font-bold text-ox-white mb-2"> | ||
| {renderInline(line.replace(/^#\s+/, ""))} | ||
| </h1> | ||
| ); | ||
| return; | ||
| } | ||
| // heading 2 | ||
| if (line.startsWith("## ")) { | ||
| flushList(); | ||
| nodes.push( | ||
| <h2 key={idx} className="text-3xl font-semibold text-ox-white mb-2"> | ||
| {renderInline(line.replace(/^##\s+/, ""))} | ||
| </h2> | ||
| ); | ||
| return; | ||
| } | ||
| // heading 3 | ||
| if (line.startsWith("### ")) { | ||
| flushList(); | ||
| nodes.push( | ||
| <h3 key={idx} className="text-2xl font-semibold text-ox-white mb-2"> | ||
| {renderInline(line.replace(/^###\s+/, ""))} | ||
| </h3> | ||
| ); | ||
| return; | ||
| } | ||
| // blockquote | ||
| if (line.startsWith("> ") || line === ">") { | ||
| blockquoteBuffer.push(raw); | ||
| return; | ||
| } | ||
|
|
||
| // image | ||
| const imgMatch = line.match(/!\[([^\]]*)\]\(([^)]+)\)/); | ||
| if (imgMatch) { | ||
| flushList(); | ||
| if (blockquoteBuffer.length) { | ||
| nodes.push( | ||
| <blockquote | ||
| key={`bq-img-${idx}`} | ||
| className="border-l-4 pl-4 italic text-ox-white/80 my-3" | ||
| > | ||
| {blockquoteBuffer.map((l, i) => ( | ||
| <p key={i} className="m-0 py-1"> | ||
| {renderInline(l.replace(/^>\s?/, ""))} | ||
| </p> | ||
| ))} | ||
| </blockquote> | ||
| ); | ||
| blockquoteBuffer = []; | ||
| } | ||
| const [, alt, url] = imgMatch; | ||
| try { | ||
| const imgUrl = new URL(url); | ||
| if (!["http:", "https:", "data:"].includes(imgUrl.protocol)) { | ||
| console.warn("Invalid image URL protocol:", url); | ||
| return; // Skip this image | ||
| } | ||
| } catch (error) { | ||
| // Relative URLs are OK, will throw but we can allow them | ||
| if (!url.startsWith("/") && !url.startsWith("./")) { | ||
| console.warn("Invalid image URL:", url); | ||
| return; | ||
| } | ||
| } | ||
| nodes.push( | ||
| <div key={idx} className="my-4"> | ||
| <img | ||
| src={url} | ||
| alt={alt} | ||
| className="w-full max-h-[500px] max-w-[1200px] rounded-md object-cover" | ||
| /> | ||
| </div> | ||
| ); | ||
| return; | ||
| } | ||
| // list | ||
| if (line.startsWith("- ")) { | ||
| listBuffer.push(line); | ||
| return; | ||
| } | ||
|
|
||
| // paragraph | ||
| flushList(); | ||
| if (blockquoteBuffer.length) { | ||
| nodes.push( | ||
| <blockquote | ||
| key={`bq-par-${idx}`} | ||
| className="border-l-4 pl-4 italic text-ox-white/80 my-3" | ||
| > | ||
| {blockquoteBuffer.map((l, i) => ( | ||
| <p key={i} className="m-0 py-1"> | ||
| {renderInline(l.replace(/^>\s?/, ""))} | ||
| </p> | ||
| ))} | ||
| </blockquote> | ||
| ); | ||
| blockquoteBuffer = []; | ||
| } | ||
| nodes.push( | ||
| <p key={idx} className="text-gray-300 leading-relaxed"> | ||
| {renderInline(line)} | ||
| </p> | ||
| ); | ||
| }); | ||
|
|
||
| flushList(); | ||
| return nodes; | ||
| } | ||
|
|
||
| export default function NewsletterArticle({ params }: params) { | ||
| const id = params.id; | ||
| const item = getNewsletterById(id); | ||
| if (!item) { | ||
| return <div className="p-6 text-ox-white">newsletter not found</div>; | ||
| } | ||
|
|
||
| return ( | ||
| <div className="p-6 min-h-screen rounded-lg mx-1 md:mx-4 bg-ox-black-1 "> | ||
| <Link | ||
| href={"/dashboard/newsletter"} | ||
| className="inline-flex items-center gap-2 mb-3 text-ox-white/90 hover:text-ox-white" | ||
| > | ||
| <ChevronLeft /> | ||
| Back | ||
| </Link> | ||
| <div className="text-ox-gray mb-6"> | ||
| {new Date(item.date).toLocaleDateString()} | ||
| </div> | ||
| <article className="prose prose-invert"> | ||
| {renderMarkdown(item.content)} | ||
| </article> | ||
| <div className="flex justify-center mt-3"> | ||
| <Link href={"/dashboard/newsletter"}> | ||
| <Button>view all newsletters</Button> | ||
| </Link> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
Tighten embed handling to truly skip invalid URLs
In the
lang === "embed"branch:returnearly when it’s nothttp/https.catchblock, you logconsole.warn("Invalid embed URL:", src);but then still proceed to push an<iframe src={src} />, so invalid/relative values are rendered despite the warning.If the intent is “only render embeds for valid HTTP(S) URLs”, consider:
if (lang === "embed") { // embed block expects a URL in the content const src = codeContent.trim(); try { const url = new URL(src); if (!["http:", "https:"].includes(url.protocol)) { console.warn("Invalid embed URL protocol:", src); - return; // skip this embed + return; // skip this embed } } catch { console.warn("Invalid embed URL:", src); - } - nodes.push( + return; // skip this embed + } + nodes.push(That way, any URL that fails parsing or protocol checks is consistently skipped.
I'll verify the review comment by inspecting the actual code at the specified location.
Length of output: 2435
Add return statement in catch block to skip invalid embed URLs
The review comment is correct. The current code validates the protocol only after successful URL parsing, but if
new URL(src)throws in the catch block, the warning is logged but the iframe still renders with the invalidsrcvalue.The suggested fix is sound: add
return;after the console.warn in the catch block (line 22) to ensure that any URL failing to parse is consistently skipped, not just URLs with invalid protocols. This prevents rendering iframes with unparseable or relative URLs.🤖 Prompt for AI Agents