This commit is contained in:
2025-01-07 20:30:40 +01:00
parent 9c82f5e5c6
commit 68b0d29b3d
15 changed files with 2884 additions and 48 deletions
+2412 -5
View File
File diff suppressed because it is too large Load Diff
+13 -1
View File
@@ -15,9 +15,21 @@
"@fortawesome/free-regular-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.2",
"@notionhq/client": "^2.2.15",
"@types/react-syntax-highlighter": "^15.5.13",
"dotenv": "^16.4.7",
"katex": "^0.16.19",
"next": "15.1.3",
"notion-to-md": "^3.1.3",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react-dom": "^19.0.0",
"react-markdown": "^9.0.3",
"react-syntax-highlighter": "^15.6.1",
"rehype-katex": "^7.0.1",
"rehype-raw": "^7.0.0",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0"
},
"devDependencies": {
"@types/node": "^20",
+17
View File
@@ -0,0 +1,17 @@
import { getBlog, getBlogs } from "@/blogApi";
import { notion, databaseId, n2m } from "@/notion";
export async function GET(request: Request) {
const url = new URL(request.url);
const id = url.searchParams.get("id");
if (id !== null) {
let [status, data] = await getBlog(notion, n2m, id);
return Response.json(data, { status });
} else {
var cursor = url.searchParams.get("cursor") || undefined;
if (cursor === "") cursor = undefined;
const size = parseInt(url.searchParams.get("size") || "5");
let [status, response] = await getBlogs(notion, databaseId, cursor, size);
return Response.json(response, { status });
}
}
+5
View File
@@ -0,0 +1,5 @@
export function GET(req: Request) {
return new Response(JSON.stringify({
version: "1.0.2",
}));
}
+11
View File
@@ -0,0 +1,11 @@
import { getBlogs, searchBlog } from "@/blogApi";
import { notion, databaseId } from "@/notion";
export async function GET(request: Request) {
const url = new URL(request.url);
let [status, response] = await searchBlog(
notion,
url.searchParams.get("query") || ""
);
return Response.json(response, { status });
}
+71
View File
@@ -0,0 +1,71 @@
"use client";
import MarkdownRenderer from "@/components/MarkdownRenderer";
import { useEffect, useState } from "react";
import "./style.css";
type Blog = {
title: string;
description: string;
id: string;
tags: { name: string; color: string }[];
markdown: string;
date: string;
};
export default function Blog({ params }: { params: Promise<{ id: string }> }) {
const [blog, setBlog] = useState<null | Blog>(null);
useEffect(() => {
params.then(({ id }) =>
fetch(`/api/blog/?id=${id}`)
.then((res) => {
res
.json()
.then((response) => {
if (res.status === 200) {
setBlog(response);
} else {
console.error(`'/api/blog/${id}' Error:`, response.msg);
}
})
.catch((e) => {
console.error(`'/api/blog/${id}' Unable to obtain json:`, e);
});
})
.catch((e) => {
console.error(`'/api/blog/${id}' Error:`, e);
})
);
}, [params]);
return (
<div>
<div className="content-center w-full lg:mx-auto pt-16 lg:pt-0 relative z-20 pb-16 md:pb-0 animate-fadeIn">
<div className="w-full max-w-screen-lg mx-auto">
<div className="px-6 md:px-10 lg:px-15 xl:px-2">
<div className="pt-8 pb-6 px-8 bg-transparent-ish rounded-xl h-screen">
{blog ? (
<div>
<h1 className="text-red-400">{blog.title}</h1>
<p className="text-gray-400">
{new Date(blog.date).toDateString()}
</p>
<div className="mt-4 blog-md">
<MarkdownRenderer>
{blog.markdown
.replaceAll(/\n!\n/g, "\n<br/>\n")
.replaceAll(/\n-/g, "\n\n-")}
</MarkdownRenderer>
</div>
</div>
) : (
""
)}
</div>
</div>
</div>
</div>
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
.blog-md h1 {
color: #fa5770;
}
.blog-md a {
color: #fa5770;
}
.blog-md code {
color: #fa5770;
}
+95
View File
@@ -0,0 +1,95 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
type Blog = {
title: string;
description: string;
id: string;
tags: { name: string; color: string }[];
};
export default function Blog() {
const [blogs, setBlogs] = useState<Blog[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const fetchBlogs = (query: string) => {
(query === ""
? fetch("/api/blog/")
: fetch(`/api/search/?query=${encodeURIComponent(query)}`)
)
.then((res) => {
res
.json()
.then((response) => {
if (response && response.items) {
setBlogs(response.items);
} else {
console.error("`/api/blog/` Response is invalid:", response);
}
})
.catch((e) => {
console.error("`/api/blog/` Unable to obtain json:", e);
});
})
.catch((e) => {
console.error("`/api/blog/` Error:", e);
});
};
useEffect(() => {
const handler = setTimeout(() => {
fetchBlogs(searchQuery);
}, 500);
return () => clearTimeout(handler);
}, [searchQuery]);
return (
<div>
<div className="content-center w-full lg:mx-auto pt-16 lg:pt-0 relative z-20 pb-16 md:pb-0 animate-fadeIn">
<div className="w-full max-w-screen-lg mx-auto">
<div className="px-6 md:px-10 lg:px-15 xl:px-2">
<div className="pt-8 pb-6 px-8 bg-transparent-ish rounded-xl h-screen">
<h1 className="text-red-400 w-max mx-auto">Leo's blog</h1>
<input
type="search"
name="Blog"
id="blog-search"
className="p-3 bg-transparent border border-red rounded-xl w-72 mx-auto mt-5"
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="Search"
/>
<div className="mt-10 grid gap-6 w-full lg:grid-cols-2">
{blogs.map((blog, i) => (
<div
className="p-3 px-5 h-52 border border-red rounded-xl backdrop-blur-xl"
key={i}
>
<h1 className="hover:text-red text-ellipsis overflow-hidden">
<Link href={"/blog/" + blog.id}>{blog.title}</Link>
</h1>
<p className="pt-1 max-h-[72px] overflow-hidden">
{blog.description}
</p>
<div className="flex gap-5 pt-3 flex-wrap overflow-hidden h-10">
{blog.tags.map((tag, i) => (
<span
className="inline-flex items-center rounded-md border px-2.5 py-0.5 font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 text-foreground text-xs bg-red-600/10 border-red-600"
key={i}
>
{tag.name}
</span>
))}
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
</div>
);
}
+3 -14
View File
@@ -1,17 +1,7 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import Navbar from "@/components/Navbar";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
import Background from "@/components/Background";
export const metadata: Metadata = {
title: "Leo's Portfolio",
@@ -25,9 +15,8 @@ export default function RootLayout({
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<body className={`antialiased`}>
<Background />
<Navbar />
{children}
</body>
-2
View File
@@ -1,4 +1,3 @@
import Background from "@/components/Background";
import PinnedRepos from "@/components/PinnedRepos";
import Skill from "@/components/Skill";
import {
@@ -16,7 +15,6 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
export default function App() {
return (
<div>
<Background />
<div className="content-center w-full lg:mx-auto pt-16 lg:pt-0 relative z-20 pb-16 md:pb-0 animate-fadeIn">
<div className="w-full max-w-screen-lg mx-auto">
<div className="px-8 md:px-10 lg:px-15 xl:px-24">
+2 -7
View File
@@ -1,15 +1,10 @@
import Background from "@/components/Background";
import {
faCheck,
faCode,
faRocket,
} from "@fortawesome/free-solid-svg-icons";
import { faCheck, faCode, faRocket } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
export default function Pricing() {
return (
<div>
<Background />
{/* <Background /> */}
<div className="content-center w-full lg:mx-auto pt-16 lg:pt-0 relative z-20 pb-16 md:pb-0 animate-fadeIn">
<div className="w-full max-w-screen-lg mx-auto px-4 md:px-10">
<h1 className="pt-10 w-max mx-auto text-red-400">Choose a plan</h1>
+129
View File
@@ -0,0 +1,129 @@
import { Client } from "@notionhq/client";
import { SearchResponse } from "@notionhq/client/build/src/api-endpoints";
import { NotionToMarkdown } from "notion-to-md";
interface Blog {
id: string;
title: string;
tags: { name: string; color: string }[];
date: string;
description: string;
}
type QueryResponse = { cursor: string | null; hasMore: boolean; items: Blog[] };
type Error = { msg: string };
export async function getBlogs(
notion: Client,
databaseId: string,
cursor: string | undefined,
pageSize: number
): Promise<[number, QueryResponse | Error]> {
try {
const response = await notion.databases.query({
database_id: databaseId,
filter: {
property: "Status",
status: {
equals: "Posted",
},
},
start_cursor: cursor,
page_size: pageSize,
});
return [
200,
{
cursor: response.next_cursor,
hasMore: response.has_more,
items: response.results.map(convertBlog),
},
];
} catch (err: any) {
return [400, { msg: String(err) }];
}
}
function convertBlog(page: any): Blog {
return {
id: page.id,
title: page.properties.Title.title[0].plain_text,
description: [
page.properties.Description.rich_text.map(
(d: { plain_text: string }) => d.plain_text
),
].join(" "),
date: page.properties.Date.created_time,
tags: page.properties.Tags.multi_select.map((tag: any) => ({
name: tag.name,
color: tag.color,
})),
};
}
interface FullBlog extends Blog {
markdown: string;
}
export async function getBlog(
notion: Client,
n2m: NotionToMarkdown,
pageId: string
): Promise<[number, FullBlog | Error]> {
try {
const page: any = await notion.pages.retrieve({ page_id: pageId });
if (page.properties.Status.status.name !== "Posted")
return [400, { msg: "Blog not found" }];
try {
const blog = convertBlog(page);
const mdBlocks = await n2m.pageToMarkdown(page.id);
return [
200,
{
id: blog.id,
title: blog.title,
description: blog.description,
tags: blog.tags,
date: blog.date,
markdown: n2m.toMarkdownString(mdBlocks).parent || "",
},
];
} catch (err: any) {
return [500, { msg: String(err) }];
}
} catch {
return [400, { msg: "Blog not found" }];
}
}
export async function searchBlog(
notion: Client,
query: string
): Promise<[200 | 400, QueryResponse | Error]> {
try {
var response = await notion.search({
query,
filter: {
value: "page",
property: "object",
},
sort: {
direction: "ascending",
timestamp: "last_edited_time",
},
});
response.results = response.results.filter(
(item: any) => item.properties.Status.status.name === "Posted"
);
return [
200,
{
cursor: response.next_cursor,
hasMore: response.has_more,
items: response.results.map(convertBlog),
},
];
} catch (err: any) {
return [400, { msg: String(err) }];
}
}
+73
View File
@@ -0,0 +1,73 @@
import React from "react";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkBreaks from "remark-breaks";
import remarkMath from "remark-math";
import rehypeRaw from "rehype-raw";
import rehypeKatex from "rehype-katex";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { gruvboxDark } from "react-syntax-highlighter/dist/esm/styles/prism";
import "katex/dist/katex.min.css";
type MarkdownRendererProps = {
children: string;
};
export default function MarkdownRenderer({
children: markdown,
}: MarkdownRendererProps) {
return (
<Markdown
remarkPlugins={[remarkGfm, remarkMath, remarkBreaks]}
rehypePlugins={[rehypeRaw, rehypeKatex]}
components={{
code({ node, inline, className, children, ...props }: any) {
const match = /language-(\w+)/.exec(className || "");
return !inline && match ? (
<SyntaxHighlighter
style={gruvboxDark}
PreTag="div"
language={match[1]}
{...props}
>
{String(children).replace(/\n$/, "")}
</SyntaxHighlighter>
) : (
<code className={className} {...props}>
{children}
</code>
);
},
ul({ children, ...props }) {
return (
<ul
style={{ listStyleType: "disc", listStylePosition: "inside" }}
{...props}
>
{children}
</ul>
);
},
ol({ children, ...props }) {
return (
<ol
style={{ listStyleType: "decimal", listStylePosition: "inside" }}
{...props}
>
{children}
</ol>
);
},
li({ children, ...props }) {
return (
<li style={{ marginBottom: "4px" }} {...props}>
{children}
</li>
);
},
}}
>
{markdown}
</Markdown>
);
}
+8
View File
@@ -1,3 +1,5 @@
import { stringify } from "querystring";
type PinnedRepo = {
author: string;
name: string;
@@ -133,6 +135,7 @@ export function Repo({
}
export default async function PinnedRepos() {
try {
const pinned: PinnedRepo[] = await (
await fetch("https://pinned.berrysauce.dev/get/kleo-dev")
).json();
@@ -153,4 +156,9 @@ export default async function PinnedRepos() {
))}
</div>
);
} catch (e: any) {
return (
<p>{e.toString()}</p>
);
}
}
+15
View File
@@ -0,0 +1,15 @@
import dotenv from 'dotenv';
import { Client } from '@notionhq/client';
import { NotionToMarkdown } from 'notion-to-md';
dotenv.config();
export const notion = new Client( { auth: process.env.NOTION_API_KEY } )
export const n2m = new NotionToMarkdown({
notionClient: notion,
});
if (process.env.NOTION_DATABASE_ID === undefined) {
console.error('[server] Error: environment variable \'NOTION_DATABASE_ID\' not found')
process.exit();
}
export const databaseId: string = process.env.NOTION_DATABASE_ID;