switched to nextjs
This commit is contained in:
@@ -1,36 +1,3 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
# `.env` requirements
|
||||
- `NOTION_API_KEY`: Go to the notion developer portal
|
||||
- `NOTION_DATABASE_ID`: the database that the api will read from
|
||||
Generated
+1564
-33
File diff suppressed because it is too large
Load Diff
+16
-6
@@ -9,14 +9,24 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "19.0.0-rc-66855b96-20241106",
|
||||
"react-dom": "19.0.0-rc-66855b96-20241106",
|
||||
"next": "15.0.3"
|
||||
"@fortawesome/fontawesome-svg-core": "^6.6.0",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.6.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.6.0",
|
||||
"@fortawesome/react-fontawesome": "^0.2.2",
|
||||
"@notionhq/client": "^2.2.15",
|
||||
"dotenv": "^16.4.5",
|
||||
"grapheme-splitter": "^1.0.4",
|
||||
"next": "15.0.3",
|
||||
"notion-to-md": "^3.1.1",
|
||||
"react": "^18.3.1",
|
||||
"react-content-loader": "^7.0.2",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-loading-skeleton": "^3.5.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"typewriter-effect": "^2.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18"
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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);}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import React from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
|
||||
export default function MarkdownRenderer({ children }: { children: string }) {
|
||||
return <ReactMarkdown>{children}</ReactMarkdown>;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
+61
-87
@@ -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
|
||||
<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);
|
||||
}}
|
||||
/>
|
||||
<ol>
|
||||
<li>
|
||||
Get started by editing <code>src/app/page.tsx</code>.
|
||||
</li>
|
||||
<li>Save and see your changes instantly.</li>
|
||||
</ol>
|
||||
</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 className="tech-stack">
|
||||
<FontAwesomeIcon icon={faRust} className="tech" />
|
||||
<FontAwesomeIcon icon={faPython} className="tech" />
|
||||
<FontAwesomeIcon icon={faGolang} className="tech" />
|
||||
<FontAwesomeIcon icon={faJs} className="tech" />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user