Skip to content

Commit b5d797f

Browse files
committed
feat: article list page
1 parent d99998a commit b5d797f

30 files changed

+633
-124
lines changed

.prettierrc

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
{
2-
"plugins": ["prettier-plugin-tailwindcss"]
2+
"plugins": ["prettier-plugin-tailwindcss"],
3+
"tailwindStylesheet": "./app/globals.css"
34
}

app/(articles)/ArticlePreview.tsx

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
"use client";
2+
3+
import Context from "@/app/components/Context";
4+
import Link from "next/link";
5+
import { useContext, useState } from "react";
6+
import { BiLinkExternal, BiSolidLock } from "react-icons/bi";
7+
import { IoEyeOffOutline } from "react-icons/io5";
8+
9+
export type Article = {
10+
image: string;
11+
title: string;
12+
description: string;
13+
metadata: {
14+
href?: string;
15+
author: string;
16+
date: string;
17+
};
18+
hidden: boolean;
19+
};
20+
21+
function MaybeLink({
22+
href,
23+
children,
24+
}: {
25+
href?: string;
26+
children: React.ReactNode;
27+
}) {
28+
if (href) {
29+
return <Link href={href}>{children}</Link>;
30+
}
31+
return <div>{children}</div>;
32+
}
33+
34+
export default function ArticlePreview({
35+
image,
36+
title,
37+
description,
38+
metadata,
39+
hidden,
40+
}: Article) {
41+
const { canHover } = useContext(Context);
42+
const [shaking, setShaking] = useState(false);
43+
const classShaking = shaking ? "animate-shake" : "";
44+
const [locking, setLocking] = useState(false);
45+
46+
return (
47+
<div
48+
className={`article-preview ${hidden && "article-hidden"} transition-transform duration-300 hover:scale-105 hover:cursor-not-allowed`}
49+
onClick={(e) => {
50+
if (!hidden) {
51+
return;
52+
}
53+
if (!canHover) {
54+
setShaking(true);
55+
setLocking(true);
56+
setTimeout(() => {
57+
setLocking(false);
58+
}, 1000);
59+
}
60+
e.preventDefault();
61+
}}
62+
>
63+
<MaybeLink href={metadata.href ?? ""}>
64+
<div
65+
style={{
66+
backgroundImage: `url(${image})`,
67+
backgroundColor: "oklch(from var(--element) l c h / 0.2)",
68+
}}
69+
className={`article-preview-card flex aspect-[16/10] h-auto w-full flex-col items-end justify-between rounded-xl object-cover px-6 py-4 transition select-none ${hidden && "opacity-85 grayscale-32"}`}
70+
title={hidden ? undefined : title}
71+
>
72+
{hidden && (
73+
<IoEyeOffOutline
74+
className="article-preview-card-lock absolute top-1/2 left-1/2 -translate-1/2 text-surface opacity-0 transition-opacity duration-300"
75+
style={{ opacity: locking ? 1 : undefined }}
76+
size="3rem"
77+
/>
78+
)}
79+
<span className={`text-xs ${hidden && "opacity-40"}`}>
80+
[ {metadata.date} ]
81+
</span>
82+
<span className={`text-xs ${hidden && "opacity-40"}`}>
83+
by [ {metadata.author} ]
84+
</span>
85+
</div>
86+
<h2
87+
className={`mt-4 mb-2 flex justify-between font-theme-sans text-xl transition-all duration-300 ${hidden && "break-words opacity-35"}`}
88+
style={{ WebkitTextStrokeWidth: "0.01em" }}
89+
>
90+
<span
91+
className={`article-preview-title mx-3 ${classShaking}`}
92+
onAnimationEnd={() => hidden && !canHover && setShaking(false)}
93+
>
94+
{title}
95+
</span>
96+
<span className="mx-5 mt-1 font-light">
97+
{hidden ? <BiSolidLock /> : <BiLinkExternal />}
98+
</span>
99+
</h2>
100+
</MaybeLink>
101+
<hr className="mb-2 h-px border-0 bg-element opacity-15" />
102+
<p
103+
className={`font-theme-sans text-sm font-light ${hidden && "break-words opacity-20"}`}
104+
>
105+
{description}
106+
</p>
107+
</div>
108+
);
109+
}

app/(articles)/HiddenPreview.tsx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"use client";
2+
import React, { useEffect, useState } from "react";
3+
import ArticlePreview, { Article } from "./ArticlePreview";
4+
5+
export default function HiddenPreview() {
6+
const [article, setArticle] = useState<Article>();
7+
8+
useEffect(() => {
9+
setArticle({
10+
image: `/covers/secret/${Math.floor(Math.random() * 10 + 1)
11+
.toString()
12+
.padStart(2, "0")}.jpg`,
13+
title: "█".repeat(Math.floor(Math.random() * 10 + 5)),
14+
description: [...Array(3)]
15+
.map(() => "█".repeat(Math.floor(Math.random() * 20 + 5)))
16+
.join(" "),
17+
metadata: {
18+
author: [...Array(2)]
19+
.map(() => "█".repeat(Math.floor(Math.random() * 8 + 3)))
20+
.join(" "),
21+
date: "█".repeat(Math.floor(Math.random() * 10 + 3)),
22+
},
23+
hidden: true,
24+
});
25+
}, []);
26+
27+
return !article ? (
28+
<React.Fragment></React.Fragment>
29+
) : (
30+
<ArticlePreview {...article} />
31+
);
32+
}

app/(articles)/articles.css

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
@reference "../globals.css";
2+
3+
.article-preview-title {
4+
@apply text-element;
5+
background-size: 200% 100%;
6+
background-image: linear-gradient(
7+
to right,
8+
var(--color-surface) 50%,
9+
var(--color-theme) 50%
10+
);
11+
transition: background-position 1s;
12+
}
13+
14+
.article-preview.article-hidden .article-preview-title {
15+
background-image: none;
16+
}
17+
18+
@media (hover: hover) {
19+
.article-preview:hover .article-preview-title {
20+
background-position: -100% 0;
21+
}
22+
23+
.article-preview.article-hidden:hover .article-preview-card-lock {
24+
@apply opacity-100;
25+
}
26+
27+
.article-preview.article-hidden:hover .article-preview-title {
28+
@apply animate-shake;
29+
}
30+
31+
.article-preview.article-hidden:hover .article-preview-card {
32+
@apply opacity-100 grayscale-16;
33+
}
34+
}

app/(articles)/ctf/layout.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type { Metadata } from "next";
2+
import "../articles.css";
3+
4+
export const metadata: Metadata = {
5+
title: "Neplox | CTF Writeups",
6+
};
7+
8+
export default function Layout({ children }: { children: React.ReactNode }) {
9+
return children;
10+
}

app/(articles)/ctf/page.tsx

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import Footer from "@/app/components/Footer";
2+
import Images from "@/app/components/Images";
3+
import Nav from "@/app/components/Nav";
4+
import ScrollDown from "@/app/components/ScrollDown";
5+
import Link from "next/link";
6+
import React from "react";
7+
import HiddenPreview from "../HiddenPreview";
8+
9+
export default function CTFArticles() {
10+
const numRandomArticles = 6;
11+
12+
return (
13+
<React.Fragment>
14+
<header className="header-grid default-header sticky top-0 z-10 flex-none pt-4 before:absolute before:inset-0 before:-z-10 before:-mx-[4vw] before:-mb-4 before:bg-surface before:shadow-[0_7px_6px_-6px_rgba(0,0,0,0.25)] md:relative md:top-0 md:pt-8 md:before:hidden">
15+
{/* Branding sm */}
16+
<Link href="/" className="justify-self-start md:hidden">
17+
{/* Same width as "go to bottom" button */}
18+
<Images.Logo className="h-auto w-12 scale-125" />
19+
</Link>
20+
{/* Branding md+ */}
21+
<Link href="/" className="hidden flex-row gap-x-8 md:flex">
22+
<Images.Logo className="h-0 min-h-full w-auto scale-125" />
23+
<h1 className="font-horizon text-[2rem] leading-none text-theme md:text-[min(10vh,6vw)]">
24+
NEPLOX
25+
</h1>
26+
</Link>
27+
28+
<div className="sm:hidden">
29+
<nav className="mx-auto flex h-full max-w-lg justify-center gap-x-4">
30+
<Nav.Element
31+
key="ctf"
32+
path="ctf"
33+
blocked={false}
34+
className="default-nav"
35+
/>
36+
</nav>
37+
</div>
38+
{/* Nav sm+ */}
39+
<div className="hidden sm:block">
40+
<nav className="mx-auto flex h-full max-w-lg justify-between gap-x-4">
41+
{Nav.paths.map(({ path, blocked }) => (
42+
<Nav.Element
43+
key={path}
44+
path={path}
45+
blocked={blocked}
46+
className="default-nav"
47+
/>
48+
))}
49+
</nav>
50+
</div>
51+
52+
<ScrollDown className="w-12 justify-self-end md:hidden" />
53+
</header>
54+
55+
<main className="mx-auto grid max-w-lg flex-auto auto-rows-max grid-cols-1 gap-x-8 gap-y-12 md:max-w-none md:grid-cols-2 lg:grid-cols-3">
56+
{[...Array(numRandomArticles)].map((_, i) => (
57+
<HiddenPreview key={i} />
58+
))}
59+
</main>
60+
61+
<Footer className="flex-none" />
62+
</React.Fragment>
63+
);
64+
}

app/components/Context.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
"use client";
2+
3+
import { createContext } from "react";
4+
5+
const Context = createContext({
6+
canHover: false,
7+
});
8+
9+
export default Context;

app/components/Footer.tsx

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import Socials from "@/app/components/Socials";
2+
import Link from "next/link";
3+
import { BiCopyright } from "react-icons/bi";
4+
import ScrollUp from "./ScrollUp";
5+
import Signature from "./Signature";
6+
7+
const footerLinks = [
8+
{ name: "about us", href: "/" },
9+
{ name: "ctf", href: "/ctf" },
10+
{ name: "extensions", href: "https://extensions.neplox.security" },
11+
];
12+
13+
export default function Footer({ className }: { className?: string }) {
14+
return (
15+
<footer className={`${className} mt-12 lg:mt-16`}>
16+
<div className="grid grid-cols-1 gap-y-8 lg:mx-auto lg:w-4/5 lg:grid-cols-2 lg:px-2">
17+
<div className="col-start-1 row-start-1 mx-auto flex w-3/4 items-center justify-evenly md:w-1/3 lg:col-start-2 lg:mx-0 lg:w-full">
18+
{Socials.map((Social) => (
19+
<a
20+
key={Social.href}
21+
href={Social.href}
22+
className="inline-block h-auto w-6 transition-transform duration-300 hover:scale-125 md:w-8"
23+
>
24+
<Social.image />
25+
</a>
26+
))}
27+
</div>
28+
<div className="col-start-1 row-start-2 mx-auto flex w-4/5 justify-center gap-x-8 px-2 lg:row-span-2 lg:row-start-1 lg:mx-0 lg:w-full lg:gap-x-12 lg:self-center lg:px-0">
29+
{footerLinks.map((link) => (
30+
<Link
31+
className="text-base whitespace-nowrap transition-transform duration-200 hover:scale-110 md:text-lg"
32+
key={link.href}
33+
href={link.href}
34+
>
35+
{link.name.toUpperCase()}
36+
</Link>
37+
))}
38+
</div>
39+
<Signature className="col-start-1 row-start-3 text-sm sm:text-base lg:col-start-2 lg:row-start-2 lg:text-lg" />
40+
</div>
41+
<hr className="mx-auto my-4 h-px w-4/5 border-0 bg-element opacity-15" />
42+
<div className="mx-auto flex w-4/5 items-center justify-between px-2">
43+
<span className="text-center text-sm font-light opacity-70 sm:text-base lg:text-lg">
44+
Neplox Team <wbr /> <BiCopyright className="inline" /> 2024 &ndash;{" "}
45+
{new Date().getFullYear()}
46+
</span>
47+
<ScrollUp />
48+
</div>
49+
</footer>
50+
);
51+
}

app/components/Images.tsx

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import Image from "next/image";
2+
3+
function Logo({ className }: { className?: string }) {
4+
return (
5+
<Image
6+
className={className}
7+
src="/icons/neplox.svg"
8+
alt="Neplox Logo"
9+
width={4122}
10+
height={3501}
11+
/>
12+
);
13+
}
14+
15+
function Twitter({ className }: { className?: string }) {
16+
return (
17+
<Image
18+
className={className}
19+
src="/icons/twitter.svg"
20+
alt="Neplox X (Twitter) Profile"
21+
width={194.97}
22+
height={194.56}
23+
/>
24+
);
25+
}
26+
27+
function GitHub({ className }: { className?: string }) {
28+
return (
29+
<Image
30+
className={className}
31+
src="/icons/github.svg"
32+
alt="Neplox GitHub Profile"
33+
width={170.67}
34+
height={166.46}
35+
/>
36+
);
37+
}
38+
39+
function Telegram({ className }: { className?: string }) {
40+
return (
41+
<Image
42+
className={className}
43+
src="/icons/telegram.svg"
44+
alt="Neplox Telegram Channel"
45+
width={217.83}
46+
height={181.33}
47+
/>
48+
);
49+
}
50+
51+
function Immunefi({ className }: { className?: string }) {
52+
return (
53+
<Image
54+
className={className}
55+
src="/icons/immunefi.svg"
56+
alt="Neplox Immunefi Profile"
57+
width={165}
58+
height={142}
59+
/>
60+
);
61+
}
62+
63+
export default {
64+
Logo,
65+
Twitter,
66+
GitHub,
67+
Telegram,
68+
Immunefi,
69+
};

0 commit comments

Comments
 (0)