switched to nextjs

This commit is contained in:
2024-11-14 16:25:56 +01:00
parent eded2294d4
commit 98c401c9e1
20 changed files with 2251 additions and 345 deletions
+104
View File
@@ -0,0 +1,104 @@
@import url(https://fonts.googleapis.com/css2?family=Montserrat:wght@400;700;800;900&display=swap);
@import url(https://fonts.googleapis.com/css?family=Roboto+Mono);
@import url(https://fonts.googleapis.com/css?family=Roboto);
p {
font-family: 'Roboto Mono';
color: white;
text-align: left;
}
body {
background-color: #101010;
margin: 0;
padding: 0;
}
h1 {
text-align: left;
}
.blog_title {
font-family: 'Montserrat';
color: white;
text-align: left;
font-weight: 800;
font-size: 36px;
}
.title {
font-family: 'Montserrat';
color: #f5304c;
text-align: left;
font-weight: 800;
font-size: 36px;
margin-top: -1%;
}
.name {
font-family: 'Roboto Mono';
color: #f5304c;
text-align: center;
font-weight: 800;
}
.date {
font-family: 'Montserrat';
font-size: 12px;
}
.description {
font-family: 'Montserrat';
font-size: 15px;
}
.markdown {
font-family: 'Roboto Mono';
margin-top: 30px;
text-align: left;
}
a {
color: #f5304c;
font-family: 'Roboto Mono';
text-decoration: none;
}
.container {
margin: 100px auto;
width: 600px;
}
@media (max-width: 600px) {
.container {
width: 100%;
}
}
.blog {
background-color: #151515;
border-radius: 10px;
border: 10px solid #151515;
margin: 20px;
transition: transform 0.4s ease;
overflow-wrap: break-word;
}
.blog.blog:hover {
transform: scale(1.03);
}
.blog_container {
margin: 60px auto;
width: 600px;
text-align: left;
overflow-wrap: break-word;
user-select: text;
overflow: hidden;
}
@media (max-width: 600px) {
.blog_container {
width: 100%;
}
}
+154
View File
@@ -0,0 +1,154 @@
@import url(https://fonts.googleapis.com/css?family=Roboto+Mono);
@import url(https://fonts.googleapis.com/css?family=Roboto);
h2, p {
font-family: 'Roboto Mono';
color: white;
margin: 0 auto;
padding: 0;
position: relative;
text-align: center;
}
body {
background-color: #101010;
margin: 0;
padding: 0;
min-height: 100vh;
}
a {
color: #f5304c;
font-family: 'Roboto Mono';
text-decoration: none;
}
@media (orientation: landscape) {
.home {
position: relative;
margin-top: -10%;
margin-left: 10%;
width: 45%;
@-moz-document url-prefix() {
margin-top: 10%;
}
}
}
@media (orientation: portrait) {
.home {
position: relative;
margin-top: 10%;
margin-left: 5%;
width: 90%;
}
}
@media (orientation: landscape) {
.logo {
width: 280px;
border-radius: 50%;
margin-left: 64%;
margin-top: 10%;
/* border: 3px solid #f5304c; */
/* outline: 10px solid #f5304c; */
/* outline-offset: 30px; */
}
}
@media (orientation: portrait) {
.logo {
width: 120px;
border-radius: 50%;
margin-left: 64%;
margin-top: 10%;
/* border: 3px solid #f5304c; */
/* outline: 10px solid #f5304c; */
/* outline-offset: 30px; */
}
}
.tech {
color: #f5304c;
width: 70px;
height: 70px;
margin-right: 15px;
}
@media (orientation: portrait) {
.tech-stack {
position: absolute;
left: 53%;
transform: translateX(-50%);
top: 86%;
}
}
@media (orientation: landscape) {
.tech-stack {
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: 2%;
}
}
@media (orientation: landscape) {
.shape-blob {
background:#f5304c;
height: 200px;
width: 200px;
border-radius: 30% 50% 20% 40%;
animation:
transform 20s ease-in-out infinite both alternate,
movement_one 40s ease-in-out infinite both;
opacity:.7;
position: absolute;
left: 70%;
top: 50%;
}
}
@media (orientation: landscape) {
.shape-blob.one{
height: 500px;
width: 500px;
left: -200px;
top: -150px;
transform: rotate(-180deg);
animation: transform 30s ease-in-out infinite both alternate, movement_two 60s ease-in-out infinite both;
}}
@media (orientation: landscape) {
.shape-blob.two{
height: 350px;
width: 350px;
left: 500px;
top: -150px;
transform: rotate(-180deg);
animation: transform 30s ease-in-out infinite both alternate, movement_two 60s ease-in-out infinite both;
}
}
@keyframes transform
{
0%,
100% { border-radius: 33% 67% 70% 30% / 30% 30% 70% 70%; }
20% { border-radius: 37% 63% 51% 49% / 37% 65% 35% 63%; }
40% { border-radius: 36% 64% 64% 36% / 64% 48% 52% 36%; }
60% { border-radius: 37% 63% 51% 49% / 30% 30% 70% 70%; }
80% { border-radius: 40% 60% 42% 58% / 41% 51% 49% 59%; }
}
@keyframes movement_one
{
0%,
100% { transform: none; }
50% { transform: translate(50%, 20%) rotateY(10deg) scale(1.2); }
}
@keyframes movement_two
{
0%,
500% { transform: none; }
50% { transform: translate(50%, 20%) rotate(-200deg) scale(1.2);}
}
+7
View File
@@ -0,0 +1,7 @@
import { getBlog } from '../blog';
import { notion, n2m } from '../notion';
export async function GET(req: Request, { params }: { params: Promise<{ blogId: string }> }) {
let [status, data] = await getBlog(notion, n2m, (await params).blogId);
return Response.json(data, { status });
}
+67
View File
@@ -0,0 +1,67 @@
import { Client } from '@notionhq/client';
import { NotionToMarkdown } from "notion-to-md";
interface Blog {
id: string,
title: string,
tags: string[],
date: string,
description: string,
};
export async function queryDatabase(notion: Client, databaseId: string): Promise<[Array<any>, any]> {
try {
const response = await notion.databases.query({
database_id: databaseId,
filter: {
property: "Status",
status: {
equals: "Posted"
}
}
});
return [response.results, null];
} catch (error: any) {
return [[], error];
}
}
export default async function getBlogs(notion: Client, databaseId: string): Promise<[number, Blog[]]> {
const [pages, err] = await queryDatabase(notion, databaseId);
if (err === null) {
return [200, pages.map(convertBlog)];
}
return [500, 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) => (tag.name)),
};
}
interface FullBlog extends Blog {
markdown: string,
};
export async function getBlog(notion: Client, n2m: NotionToMarkdown, pageId: string): Promise<[number, FullBlog]> {
try {
const page = await notion.pages.retrieve({ page_id: pageId })
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 (error: any) {
return [400, error];
}
}
+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;
+7
View File
@@ -0,0 +1,7 @@
import getBlogs from './blog';
import { notion, databaseId } from './notion';
export async function GET() {
let [status, data] = await getBlogs(notion, databaseId);
return Response.json(data, { status });
}
+119
View File
@@ -0,0 +1,119 @@
"use client";
import { useEffect, useState } from "react";
import ContentLoader from "react-content-loader"
export default function LoadingBlog(props: any) {
const [numComponents, setNumComponents] = useState(0);
const [isClient, setIsClient] = useState(false);
useEffect(() => {
// Only set to client after mount
setIsClient(true);
}, []);
useEffect(() => {
const updateComponentCount = () => {
const windowHeight = window.innerHeight; // Get window height
const componentHeight = 100; // Set the height of your component
const count = Math.floor(windowHeight / componentHeight); // Calculate how many components fit
setNumComponents(count);
};
// Initial calculation
updateComponentCount();
// Add resize event listener
window.addEventListener('resize', updateComponentCount);
// Cleanup listener on unmount
return () => {
window.removeEventListener('resize', updateComponentCount);
};
}, []); // Empty dependency array ensures the effect runs once after the initial render
// Only render on the client
if (!isClient) {
return null;
}
return (
<div className="container">
{Array.from({ length: numComponents }, (_, index) => (
<ContentLoader
speed={2}
viewBox="0 0 340 50"
backgroundColor="#3b3b3b"
foregroundColor="#f5304c"
key={index}
{...props}
>
<rect x="0" y="0" rx="7" ry="7" width="290" height="18" />
<rect x="0" y="25" rx="3" ry="3" width="78" height="7" />
<rect x="0" y="37" rx="3" ry="3" width="200" height="7" />
</ContentLoader>
))}
</div>
);
}
export function LoadingBlogRead(props: any) {
const [isClient, setIsClient] = useState(false);
const [numRects, setNumRects] = useState(0);
useEffect(() => {
setIsClient(true);
}, []);
useEffect(() => {
const updateRectCount = () => {
// Calculate the number of rectangles based on window height
const windowHeight = window.innerHeight;
const rectHeight = 46; // Height of each rectangle
const count = Math.floor(windowHeight / rectHeight); // Number of rectangles that fit
setNumRects(count);
};
// Update on mount and window resize
updateRectCount();
window.addEventListener('resize', updateRectCount);
// Cleanup event listener on unmount
return () => {
window.removeEventListener('resize', updateRectCount);
};
}, []);
// Only render on the client
if (!isClient) {
return null;
}
// Dynamically set the viewBox height based on the number of rectangles
const viewBoxHeight = window.innerHeight / 3;
return (
<ContentLoader
speed={2}
viewBox={`0 0 340 ${viewBoxHeight}`}
backgroundColor="#3b3b3b"
foregroundColor="#f5304c"
style={{ marginTop: "20px" }}
{...props}
>
<rect x="0" y="0" rx="7" ry="7" width="290" height="18" />
<rect x="0" y="25" rx="3" ry="3" width="78" height="7" />
{Array.from({ length: numRects }).map((_, i) => (
<rect
x="0"
y={45 + i * 14} // Adjust position based on index
rx="3"
ry="3"
width={Math.random() * (300 - 80) + 80} // Random width
height="9"
key={i}
/>
))}
</ContentLoader>
);
}
+6
View File
@@ -0,0 +1,6 @@
import React from 'react';
import ReactMarkdown from 'react-markdown';
export default function MarkdownRenderer({ children }: { children: string }) {
return <ReactMarkdown>{children}</ReactMarkdown>;
};
+60
View File
@@ -0,0 +1,60 @@
"use client";
import React, { useEffect, useState } from 'react';
import '../../Blog.css';
import MarkdownRenderer from './MarkdownRenderer';
import LoadingBlog, { LoadingBlogRead } from '../Loading';
export default function Blog({ params }: { params: Promise<{ id: string }> }) {
const [blog, setBlog] = useState({ title: '', date: '', markdown: '' });
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
const date = new Date(blog.date).toLocaleString('default', { month: 'long', day: 'numeric', year: 'numeric' });
useEffect(() => {
const fetchBlog = async () => {
try {
console.log('obtaining blog')
const response = await fetch(`/api/${(await params).id}`);
if (response.ok) {
const blog = await response.json();
document.title = blog.title + " - Leo's blog";
setBlog(blog);
} else {
const errorData = await response.text();
setError(errorData);
}
} catch (err: any) {
setError(err);
} finally {
setLoading(false);
}
};
fetchBlog();
}, [params]);
if (loading) {
return <main>
<a href="/blog"><h1 className='name'>Leo's blog</h1></a>
<div className='blog_container'>
<LoadingBlogRead/>
</div>
</main>;
}
if (error) {
return <div className="error">Error loading blog: {error}</div>; // Show error if there is one
}
return (
<main>
<a href="/blog"><h1 className='name'>Leo's blog</h1></a>
<div className='blog_container'>
<h2 className='blog_title'>{blog.title}</h2>
<p className='date'>{date}</p>
<div className='markdown'><MarkdownRenderer>{blog.markdown}</MarkdownRenderer></div>
</div>
</main>
);
}
+63
View File
@@ -0,0 +1,63 @@
"use client";
import React, { useEffect, useState } from 'react';
import '../Blog.css';
import LoadingBlog from './Loading';
export default function Blog() {
const [data, setData] = useState([{ title: '', description: '', id: '', date: '' }]);
const [error, setError] = useState('');
useEffect(() => {
const fetchData = async () => {
setTimeout(async () => {
try {
const response = await fetch('/api/');
if (response.ok) {
const result = await response.json();
setData(result);
} else {
const errorData = await response.text();
setError(errorData);
}
} catch (err: any) {
setError(err.toString());
}
}, 500);
};
fetchData();
}, []);
if (error) {
return <div>Error: {JSON.stringify(error)}</div>; // Display any errors
} if (data.length === 1 && data[0].id === '') {
return (
<main>
<a href="/blog"><h1 className='name'>Leo's blog</h1></a>
<LoadingBlog/>
</main>
);
}
document.title = "Leo's blog";
return (
<main>
<a href="/blog"><h1 className='name'>Leo's blog</h1></a>
<div className='container'>
{data.map((blog, i) => {
return (
<a key={i} href={`/blog/${blog.id}`} style={{ textDecoration: 'none' }}>
<div className='blog'>
<h2 className='title'>{blog.title}</h2>
<p className='date'>{new Date(blog.date).toLocaleString('default', { month: 'long', day: 'numeric', year: 'numeric' })}</p>
<p className='description'>{blog.description}</p>
</div>
</a>
);
})}
</div>
</main>
);
}
Binary file not shown.
Binary file not shown.
+3 -13
View File
@@ -1,18 +1,6 @@
import type { Metadata } from "next";
import localFont from "next/font/local";
import "./globals.css";
const geistSans = localFont({
src: "./fonts/GeistVF.woff",
variable: "--font-geist-sans",
weight: "100 900",
});
const geistMono = localFont({
src: "./fonts/GeistMonoVF.woff",
variable: "--font-geist-mono",
weight: "100 900",
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
@@ -25,7 +13,9 @@ export default function RootLayout({
}>) {
return (
<html lang="en">
<body className={`${geistSans.variable} ${geistMono.variable}`}>
<title>Leo's portfolio</title>
<link rel="icon" type="image/x-icon" href="favicon.ico"></link>
<body>
{children}
</body>
</html>
-168
View File
@@ -1,168 +0,0 @@
.page {
--gray-rgb: 0, 0, 0;
--gray-alpha-200: rgba(var(--gray-rgb), 0.08);
--gray-alpha-100: rgba(var(--gray-rgb), 0.05);
--button-primary-hover: #383838;
--button-secondary-hover: #f2f2f2;
display: grid;
grid-template-rows: 20px 1fr 20px;
align-items: center;
justify-items: center;
min-height: 100svh;
padding: 80px;
gap: 64px;
font-family: var(--font-geist-sans);
}
@media (prefers-color-scheme: dark) {
.page {
--gray-rgb: 255, 255, 255;
--gray-alpha-200: rgba(var(--gray-rgb), 0.145);
--gray-alpha-100: rgba(var(--gray-rgb), 0.06);
--button-primary-hover: #ccc;
--button-secondary-hover: #1a1a1a;
}
}
.main {
display: flex;
flex-direction: column;
gap: 32px;
grid-row-start: 2;
}
.main ol {
font-family: var(--font-geist-mono);
padding-left: 0;
margin: 0;
font-size: 14px;
line-height: 24px;
letter-spacing: -0.01em;
list-style-position: inside;
}
.main li:not(:last-of-type) {
margin-bottom: 8px;
}
.main code {
font-family: inherit;
background: var(--gray-alpha-100);
padding: 2px 4px;
border-radius: 4px;
font-weight: 600;
}
.ctas {
display: flex;
gap: 16px;
}
.ctas a {
appearance: none;
border-radius: 128px;
height: 48px;
padding: 0 20px;
border: none;
border: 1px solid transparent;
transition:
background 0.2s,
color 0.2s,
border-color 0.2s;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
line-height: 20px;
font-weight: 500;
}
a.primary {
background: var(--foreground);
color: var(--background);
gap: 8px;
}
a.secondary {
border-color: var(--gray-alpha-200);
min-width: 180px;
}
.footer {
grid-row-start: 3;
display: flex;
gap: 24px;
}
.footer a {
display: flex;
align-items: center;
gap: 8px;
}
.footer img {
flex-shrink: 0;
}
/* Enable hover only on non-touch devices */
@media (hover: hover) and (pointer: fine) {
a.primary:hover {
background: var(--button-primary-hover);
border-color: transparent;
}
a.secondary:hover {
background: var(--button-secondary-hover);
border-color: transparent;
}
.footer a:hover {
text-decoration: underline;
text-underline-offset: 4px;
}
}
@media (max-width: 600px) {
.page {
padding: 32px;
padding-bottom: 80px;
}
.main {
align-items: center;
}
.main ol {
text-align: center;
}
.ctas {
flex-direction: column;
}
.ctas a {
font-size: 14px;
height: 40px;
padding: 0 16px;
}
a.secondary {
min-width: auto;
}
.footer {
flex-wrap: wrap;
align-items: center;
justify-content: center;
}
}
@media (prefers-color-scheme: dark) {
.logo {
filter: invert();
}
}
+63 -89
View File
@@ -1,95 +1,69 @@
import Image from "next/image";
import styles from "./page.module.css";
"use client";
import './Page.css';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faGolang, faJs, faPython, faRust } from '@fortawesome/free-brands-svg-icons';
import GraphemeSplitter from 'grapheme-splitter';
import dynamic from 'next/dynamic';
const Typewriter = dynamic(() => import('typewriter-effect'), { ssr: false });
const stringSplitter = (text: string): any => {
const splitter = new GraphemeSplitter();
return splitter.splitGraphemes(text);
};
export default function Home() {
return (
<div className={styles.page}>
<main className={styles.main}>
<Image
className={styles.logo}
src="/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol>
<li>
Get started by editing <code>src/app/page.tsx</code>.
</li>
<li>Save and see your changes instantly.</li>
</ol>
<div className="app">
<img src="logo.png" alt="Logo" className="logo" />
<div className="shape-blob"></div>
<div className="shape-blob one"></div>
<div className="shape-blob two"></div>
<div className="home">
<h2>
<Typewriter
options={{
stringSplitter,
loop: true,
}}
onInit={(typewriter) => {
typewriter
.typeString("Hey, I'm Leo 👋🏻")
.start()
.pauseFor(2400)
.deleteAll()
.typeString("A software developer")
.pauseFor(2400)
.changeDeleteSpeed(10)
.deleteChars(18)
.typeString("Cybersecurity engineer")
.pauseFor(2400)
.deleteChars(22)
.typeString("Computer architect")
.pauseFor(2400);
}}
/>
</h2>
<p>
I'm a developer and researcher from Tirane, Albania. I'm the leader of{' '}
<a href="https://github.com/orus-dev">Orus</a>. I've been coding for 5
years and I'm in love with it. I work on free and open source projects.
I'm also willing to work on paid projects with a payment. You can find
me on <a href="https://instagram.com/kleo.dev">Instagram</a>,{' '}
<a href="https://github.com/kleo-dev">GitHub</a>,{' '}
<a href="https://www.youtube.com/@0xkleo">YouTube</a>, Or if you
prefer to contact me via Discord: leocodes. You can also check out{' '}
<a href="/blog">My Blog</a>
</p>
</div>
<div className={styles.ctas}>
<a
className={styles.primary}
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className={styles.logo}
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
className={styles.secondary}
>
Read our docs
</a>
</div>
</main>
<footer className={styles.footer}>
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org →
</a>
</footer>
<div className="tech-stack">
<FontAwesomeIcon icon={faRust} className="tech" />
<FontAwesomeIcon icon={faPython} className="tech" />
<FontAwesomeIcon icon={faGolang} className="tech" />
<FontAwesomeIcon icon={faJs} className="tech" />
</div>
</div>
);
}
};