Skip to content

Commit a0ea83c

Browse files
committed
feat(portfolio): add experimentally mock route (#418)
- closed: #418
1 parent 8e834f9 commit a0ea83c

File tree

8 files changed

+634
-7
lines changed

8 files changed

+634
-7
lines changed

Diff for: apps/web/src/app/mock/[slug]/page.tsx

+129
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { Suspense } from 'react';
2+
import type { Metadata } from 'next';
3+
import { notFound } from 'next/navigation';
4+
import { unstable_noStore as noStore } from 'next/cache';
5+
import MarkdownRenderer from '@/components/markdown/markdown-renderer';
6+
import PageHeader from '@/components/page-header';
7+
import { getPortfolioPosts } from '@/lib/db/portfolio';
8+
9+
import "@/styles/blog/blog-text.css"
10+
11+
export async function generateMetadata({
12+
params,
13+
}: {
14+
params: { slug: string };
15+
}): Promise<Metadata | undefined> {
16+
let post = getPortfolioPosts().find((post) => post.slug === params.slug);
17+
if (!post) {
18+
return;
19+
}
20+
21+
let {
22+
title,
23+
publishedAt: publishedTime,
24+
summary: description,
25+
banner,
26+
} = post.metadata;
27+
let ogImage = banner
28+
? `https://1chooo.com${banner}`
29+
: `https://1chooo.com/og?title=${title}`;
30+
31+
return {
32+
title,
33+
description,
34+
openGraph: {
35+
title,
36+
siteName: 'Chun-Ho (Hugo) Lin - 1chooo | Open Source Enthusiast',
37+
description,
38+
type: 'article',
39+
publishedTime,
40+
url: `https://1chooo.com/mock/${post.slug}`,
41+
locale: 'en_US',
42+
images: [
43+
{
44+
url: ogImage,
45+
},
46+
],
47+
},
48+
twitter: {
49+
card: 'summary_large_image',
50+
title,
51+
description,
52+
images: [ogImage],
53+
},
54+
};
55+
}
56+
57+
function formatDate(date: string) {
58+
noStore();
59+
let currentDate = new Date().getTime();
60+
if (!date.includes('T')) {
61+
date = `${date}T00:00:00`;
62+
}
63+
let targetDate = new Date(date).getTime();
64+
let timeDifference = Math.abs(currentDate - targetDate);
65+
let daysAgo = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
66+
67+
let fullDate = new Date(date).toLocaleString('en-us', {
68+
month: 'long',
69+
day: 'numeric',
70+
year: 'numeric',
71+
});
72+
73+
let daysLater: number = 0;
74+
if (targetDate > currentDate) {
75+
daysLater = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
76+
}
77+
78+
if (daysLater > 365) {
79+
return `${fullDate} (${daysLater}d later)`;
80+
} else if (daysLater > 30) {
81+
const weeksAgo = Math.floor(daysLater / 7)
82+
return `${fullDate} (${weeksAgo}w later)`;
83+
} else if (daysLater > 7) {
84+
const monthsAgo = Math.floor(daysLater / 30)
85+
return `${fullDate} (${monthsAgo}mo later)`;
86+
} else if (daysAgo < 1) {
87+
return 'Today';
88+
} else if (daysAgo < 7) {
89+
return `${fullDate} (${daysAgo}d ago)`;
90+
} else if (daysAgo < 30) {
91+
const weeksAgo = Math.floor(daysAgo / 7)
92+
return `${fullDate} (${weeksAgo}w ago)`;
93+
} else if (daysAgo < 365) {
94+
const monthsAgo = Math.floor(daysAgo / 30)
95+
return `${fullDate} (${monthsAgo}mo ago)`;
96+
} else {
97+
const yearsAgo = Math.floor(daysAgo / 365)
98+
return `${fullDate} (${yearsAgo}y ago)`;
99+
}
100+
}
101+
102+
export default function Blog({ params }: { params: { slug: string } }) {
103+
let post = getPortfolioPosts().find((post) => post.slug === params.slug);
104+
105+
if (!post) {
106+
notFound();
107+
}
108+
109+
return (
110+
<div>
111+
<article>
112+
<section className="blog-text">
113+
<PageHeader header="Hugo's Blog" />
114+
<h1 className="title font-medium text-2xl tracking-tighter max-w-[650px]">
115+
<MarkdownRenderer content={post.metadata.title} />
116+
</h1>
117+
<div className="flex justify-between items-center mt-2 mb-8 text-sm max-w-[650px]">
118+
<Suspense fallback={<p className="h-5" />}>
119+
<p className="text-sm text-neutral-600 dark:text-neutral-400">
120+
{formatDate(post.metadata.publishedAt)}
121+
</p>
122+
</Suspense>
123+
</div>
124+
<MarkdownRenderer content={post.content} />
125+
</section>
126+
</article>
127+
</div>
128+
);
129+
}

Diff for: apps/web/src/app/mock/page.tsx

+113
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import Link from "next/link";
2+
import Image from "next/image";
3+
import PageHeader from "@/components/page-header";
4+
import FilterSelectBox from "@/components/portfolio/v2/filter-select-box";
5+
import FilterList from "@/components/portfolio/v2/filter-list";
6+
import MarkdownRenderer from "@/components/markdown/markdown-renderer";
7+
import { getPortfolioPosts } from "@/lib/db/portfolio";
8+
import { POSTS_PER_PAGE } from "@/lib/constants";
9+
import config from "@/config";
10+
import { LuEye } from "react-icons/lu";
11+
12+
const { title } = config;
13+
14+
import "react-loading-skeleton/dist/skeleton.css";
15+
16+
export const metadata = {
17+
title: `Portfolio | ${title}`,
18+
description: "Read my thoughts on software development, design, and more.",
19+
};
20+
21+
export default function Portfolio({
22+
searchParams,
23+
}: {
24+
searchParams: { tag?: string; page?: string };
25+
}) {
26+
let allBlogs = getPortfolioPosts();
27+
const blogTags = [
28+
"All",
29+
...Array.from(
30+
new Set(allBlogs.map((post) => post.metadata.category ?? ""))
31+
),
32+
];
33+
const selectedTag = searchParams.tag || "All";
34+
const currentPage = parseInt(searchParams.page || "1", 10);
35+
36+
// Filter blogs based on the selected tag
37+
const filteredBlogs =
38+
selectedTag === "All"
39+
? allBlogs
40+
: allBlogs.filter((post) => post.metadata.category === selectedTag);
41+
42+
// Sort blogs by date
43+
const sortedBlogs = filteredBlogs.sort((a, b) => {
44+
if (new Date(a.metadata.publishedAt) > new Date(b.metadata.publishedAt)) {
45+
return -1;
46+
}
47+
return 1;
48+
});
49+
50+
// Calculate total pages
51+
const totalPages = Math.ceil(sortedBlogs.length / POSTS_PER_PAGE);
52+
53+
// Get blogs for current page
54+
const paginatedBlogs = sortedBlogs.slice(
55+
(currentPage - 1) * POSTS_PER_PAGE,
56+
currentPage * POSTS_PER_PAGE
57+
);
58+
59+
return (
60+
<article>
61+
<PageHeader header="Hugo's Portfolio" />
62+
<section className="projects">
63+
<FilterList selectedTag={selectedTag} blogTags={blogTags} />
64+
<FilterSelectBox selectedTag={selectedTag} blogTags={blogTags} />
65+
<ul className="project-list">
66+
{paginatedBlogs.map((post, index) => (
67+
<li
68+
key={index}
69+
className="project-item active"
70+
data-category={post.metadata.category}
71+
>
72+
<Link href={`/mock/${post.slug}`} rel="noopener noreferrer">
73+
<figure className="project-img">
74+
<div className="project-item-icon-box">
75+
<LuEye />
76+
</div>
77+
<Image
78+
src={post.metadata.banner}
79+
alt={post.metadata.alt || "Portfolio post image"}
80+
width={1600}
81+
height={900}
82+
priority={true}
83+
placeholder="empty"
84+
loading="eager"
85+
/>
86+
</figure>
87+
<h3 className="project-title"><MarkdownRenderer content={post.metadata.title} /></h3>
88+
<p className="project-category">{post.metadata.category}</p>
89+
</Link>
90+
</li>
91+
))}
92+
</ul>
93+
<div className="pagination">
94+
{Array.from({ length: totalPages }, (_, i) => i + 1).map(
95+
(pageNum) => (
96+
<Link
97+
key={pageNum}
98+
href={{
99+
pathname: "/mock",
100+
query: { ...searchParams, page: pageNum.toString() },
101+
}}
102+
className={`pagination-btn ${pageNum === currentPage ? "active" : ""
103+
}`}
104+
>
105+
{pageNum}
106+
</Link>
107+
)
108+
)}
109+
</div>
110+
</section>
111+
</article>
112+
);
113+
}

Diff for: apps/web/src/components/portfolio/v2/filter-list.tsx

+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import React from 'react';
2+
import Link from 'next/link';
3+
4+
interface FilterListProps {
5+
selectedTag: string;
6+
blogTags: string[];
7+
}
8+
9+
const FilterList: React.FC<FilterListProps> = ({
10+
selectedTag,
11+
blogTags
12+
}) => {
13+
14+
return (
15+
<ul className="filter-list">
16+
{blogTags.map((tag, index) => (
17+
<li className="filter-item" key={index}>
18+
<Link
19+
href={`/mock?tag=${encodeURIComponent(tag || '')}`}
20+
className={`filter-btn ${selectedTag === tag ? 'active' : ''}`}
21+
>
22+
{tag}
23+
</Link>
24+
</li>
25+
))}
26+
</ul>
27+
);
28+
};
29+
30+
export default FilterList;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"use client";
2+
3+
import React, { useState } from 'react';
4+
import { MdExpandMore } from 'react-icons/md';
5+
import Link from 'next/link';
6+
7+
interface FilterSelectBoxProps {
8+
selectedTag: string;
9+
blogTags: string[];
10+
}
11+
12+
const FilterSelectBox: React.FC<FilterSelectBoxProps> = ({
13+
selectedTag,
14+
blogTags
15+
}) => {
16+
const [isSelectActive, setIsSelectActive] = useState(false);
17+
18+
return (
19+
<div className="filter-select-box">
20+
<button
21+
className={`filter-select ${isSelectActive ? 'active' : ''}`}
22+
onClick={() => setIsSelectActive(!isSelectActive)}
23+
>
24+
<div className="select-value">
25+
{selectedTag || 'Select category'}
26+
</div>
27+
<div className="select-icon">
28+
<MdExpandMore />
29+
</div>
30+
</button>
31+
{isSelectActive && (
32+
<ul className="select-list">
33+
{blogTags.map((tag: string) => (
34+
<li className="select-item" key={tag}>
35+
<button
36+
onClick={() => {
37+
setIsSelectActive(false);
38+
}}
39+
>
40+
<Link href={`/mock?tag=${encodeURIComponent(tag || '')}`}>
41+
{tag}
42+
</Link>
43+
</button>
44+
</li>
45+
))}
46+
</ul>
47+
)}
48+
</div>
49+
);
50+
};
51+
52+
export default FilterSelectBox;

0 commit comments

Comments
 (0)