add initial
This commit is contained in:
commit
792d71523c
41
.gitignore
vendored
Normal file
41
.gitignore
vendored
Normal file
@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"editor.formatOnSave": true
|
||||
}
|
36
README.md
Normal file
36
README.md
Normal file
@ -0,0 +1,36 @@
|
||||
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.
|
26
cli/cli.ts
Normal file
26
cli/cli.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import argon2 from "argon2";
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const username = args[0];
|
||||
const password = args[1];
|
||||
|
||||
if (!username || !password) {
|
||||
console.error("Usage: node cli.js <username>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const hash = await argon2.hash(password, {
|
||||
type: argon2.argon2id,
|
||||
memoryCost: 19456,
|
||||
timeCost: 2,
|
||||
parallelism: 1,
|
||||
});
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
await prisma.user.deleteMany({ where: { username } });
|
||||
await prisma.user.create({ data: { username, password: hash } });
|
||||
}
|
||||
|
||||
main();
|
16
eslint.config.mjs
Normal file
16
eslint.config.mjs
Normal file
@ -0,0 +1,16 @@
|
||||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
5
next.config.ts
Normal file
5
next.config.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {};
|
||||
|
||||
export default nextConfig;
|
5792
package-lock.json
generated
Normal file
5792
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
package.json
Normal file
41
package.json
Normal file
@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "mainsite",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@icons-pack/react-simple-icons": "^12.3.0",
|
||||
"@prisma/client": "^6.5.0",
|
||||
"argon2": "^0.41.1",
|
||||
"cheerio": "^1.0.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"jose": "^6.0.10",
|
||||
"lucide-react": "^0.485.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"next": "15.2.4",
|
||||
"nodemailer": "^6.10.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-google-recaptcha": "^3.1.0",
|
||||
"react-social-icons": "^6.22.0",
|
||||
"slugify": "^1.6.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@types/highlight.js": "^9.12.4",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/node": "^20",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/react-google-recaptcha": "^2.1.9",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.2.4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
32
prisma/schema.prisma
Normal file
32
prisma/schema.prisma
Normal file
@ -0,0 +1,32 @@
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
model Post {
|
||||
id Int @id @default(autoincrement())
|
||||
slug String @unique @db.Text
|
||||
title String @db.Text
|
||||
contentRendered Json @db.Json
|
||||
contentMarkdown String @db.Text
|
||||
blurb String @db.Text
|
||||
publishedDate DateTime @db.Timestamp(6)
|
||||
is_draft Boolean
|
||||
tags Tag[]
|
||||
}
|
||||
|
||||
model Tag {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
posts Post[]
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
username String @unique
|
||||
password String
|
||||
}
|
16
src/app/about/page.tsx
Normal file
16
src/app/about/page.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
/* eslint-disable react/no-unescaped-entities */
|
||||
|
||||
export default function About() {
|
||||
return (
|
||||
<>
|
||||
so hey, i'm naresh!
|
||||
<br />
|
||||
<br />
|
||||
i'm an engineer and i love building things and tinkering on things.
|
||||
<br />
|
||||
<br /> i like going on runs and going for rides on my bike.
|
||||
<br />
|
||||
<br /> umm, maybe i'll write more here later.
|
||||
</>
|
||||
);
|
||||
}
|
53
src/app/blog/[[...tag]]/Pagination.tsx
Normal file
53
src/app/blog/[[...tag]]/Pagination.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
|
||||
export default function Pagination({
|
||||
numberOfPages,
|
||||
pageNumber,
|
||||
}: {
|
||||
numberOfPages: number;
|
||||
pageNumber: number;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const setPageNumber = (page: number) => {
|
||||
router.push(
|
||||
pathname + "?" + new URLSearchParams({ page: page.toString() })
|
||||
);
|
||||
};
|
||||
|
||||
if (numberOfPages <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
marginTop: "1rem",
|
||||
gap: "0.5rem",
|
||||
fontSize: "0.9rem",
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: numberOfPages }, (_, i) => (
|
||||
<span
|
||||
key={i + 1}
|
||||
style={{
|
||||
width: "0.5rem",
|
||||
padding: "0.5rem",
|
||||
cursor: "pointer",
|
||||
backgroundColor: pageNumber === i + 1 ? "#333" : "#eee",
|
||||
color: "#222",
|
||||
justifyContent: "center",
|
||||
display: "flex",
|
||||
}}
|
||||
onClick={() => setPageNumber(i + 1)}
|
||||
>
|
||||
{i + 1}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
134
src/app/blog/[[...tag]]/PostSummary.tsx
Normal file
134
src/app/blog/[[...tag]]/PostSummary.tsx
Normal file
@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Summary } from "../types";
|
||||
|
||||
export default function PostSummary({ metadata }: { metadata: Summary }) {
|
||||
const [elementColor, setElementColor] = useState("#999");
|
||||
const router = useRouter();
|
||||
|
||||
const hoverStart = () => {
|
||||
setElementColor("#eee");
|
||||
};
|
||||
const hoverEnd = () => {
|
||||
setElementColor("#999");
|
||||
};
|
||||
const navigateToPost = () => {
|
||||
router.push(`/blog/post/${metadata.slug}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={metadata.slug}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
borderColor: elementColor,
|
||||
transition: "border-color 0.3s linear",
|
||||
borderStyle: "solid",
|
||||
marginTop: "1rem",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
backgroundColor: elementColor,
|
||||
color: "#111",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: "0.5rem",
|
||||
boxSizing: "border-box",
|
||||
fontSize: "1rem",
|
||||
cursor: "pointer",
|
||||
transition: "background-color 0.3s linear",
|
||||
}}
|
||||
onMouseEnter={hoverStart}
|
||||
onMouseLeave={hoverEnd}
|
||||
onClick={navigateToPost}
|
||||
>
|
||||
<span>{metadata.title}</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
fontSize: "0.9rem",
|
||||
maxWidth: "100%",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-start",
|
||||
padding: "0.5rem",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
tags: {metadata.tags.join(", ")}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
padding: "0.5rem",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<span>Published: </span>
|
||||
<span>{metadata.publishedDate.toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "2px",
|
||||
backgroundColor: elementColor,
|
||||
transition: "background-color 0.3s linear",
|
||||
}}
|
||||
></div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
padding: "1rem 0.5rem 0 0.5rem",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: "0.9rem" }}>
|
||||
{metadata.blurb}
|
||||
<span style={{ color: "#999" }}>…</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
marginTop: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
textDecoration: "none",
|
||||
color: "#222",
|
||||
transition: "background-color 0.3s linear",
|
||||
padding: "0.5rem 1rem",
|
||||
fontSize: "0.9rem",
|
||||
fontWeight: "500",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.25rem",
|
||||
cursor: "pointer",
|
||||
backgroundColor: elementColor,
|
||||
}}
|
||||
onMouseEnter={hoverStart}
|
||||
onMouseLeave={hoverEnd}
|
||||
onClick={navigateToPost}
|
||||
>
|
||||
Read more <span style={{ fontSize: "1rem" }}>→</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
93
src/app/blog/[[...tag]]/TagOverview.tsx
Normal file
93
src/app/blog/[[...tag]]/TagOverview.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import React from "react";
|
||||
|
||||
function Tag({ tag }: { tag: string }) {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
fontWeight: "bold",
|
||||
cursor: "pointer",
|
||||
padding: "8px 6px",
|
||||
display: "inline-block",
|
||||
transition: "color 0.3s linear, font-size 0.3s ease",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = "#fff";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = "";
|
||||
}}
|
||||
onClick={() => router.push(`/blog/${tag}`)}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function TagInfo({ tag }: { tag: string }) {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
marginTop: "1rem",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<span>showing posts tagged "{tag}"</span>
|
||||
<span
|
||||
style={{
|
||||
padding: "0.5rem 1rem",
|
||||
backgroundColor: "#333",
|
||||
width: "fit-content",
|
||||
transition: "background-color 0.3s linear",
|
||||
cursor: "pointer",
|
||||
userSelect: "none",
|
||||
}}
|
||||
onMouseOver={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "#444";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "#333";
|
||||
}}
|
||||
onClick={() => {
|
||||
router.push("/blog");
|
||||
}}
|
||||
>
|
||||
see all posts
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TagOverview({
|
||||
currentTag,
|
||||
tags,
|
||||
}: {
|
||||
currentTag: string | null;
|
||||
tags: string[];
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{!currentTag && (
|
||||
<div style={{ fontSize: 12, marginTop: "1rem" }}>
|
||||
tags:
|
||||
{tags.map((tag, i) => (
|
||||
<React.Fragment key={tag}>
|
||||
<Tag tag={tag} />
|
||||
{i < tags.length - 1 && (
|
||||
<span style={{ color: "#999" }}> | </span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{currentTag && <TagInfo tag={currentTag} />}
|
||||
</>
|
||||
);
|
||||
}
|
61
src/app/blog/[[...tag]]/page.tsx
Normal file
61
src/app/blog/[[...tag]]/page.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import { getSummaries, getTags } from "../action";
|
||||
import { titleFont } from "@/components/fonts";
|
||||
import { notFound } from "next/navigation";
|
||||
import React from "react";
|
||||
import PostSummary from "./PostSummary";
|
||||
import Pagination from "./Pagination";
|
||||
import TagOverview from "./TagOverview";
|
||||
|
||||
async function getPageNumber(
|
||||
searchParams: Promise<{ page: string | undefined }>
|
||||
) {
|
||||
const { page } = await searchParams;
|
||||
const result = page ? parseInt(page) : 1;
|
||||
if (isNaN(result) || result < 1) {
|
||||
notFound();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export default async function Blog({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ tag: string[] | undefined }>;
|
||||
searchParams: Promise<{ page: string | undefined }>;
|
||||
}) {
|
||||
const pageNumber = await getPageNumber(searchParams);
|
||||
const { tag } = await params;
|
||||
const currentTag = tag != null && tag.length >= 1 ? tag[0] : null;
|
||||
if ((tag?.length ?? 0) > 1) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { metadata, numberOfPages } = await getSummaries(
|
||||
pageNumber,
|
||||
currentTag
|
||||
);
|
||||
if (pageNumber > numberOfPages) {
|
||||
notFound();
|
||||
}
|
||||
const tags = await getTags();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
className={titleFont.className}
|
||||
>
|
||||
<span style={{ fontSize: 18 }}>naresh writes</span>
|
||||
<span style={{ fontSize: 12 }}>...occasionally</span>
|
||||
</div>
|
||||
<TagOverview tags={tags} currentTag={currentTag} />
|
||||
{metadata.length > 0 &&
|
||||
metadata.map((m) => <PostSummary metadata={m} key={m.slug} />)}
|
||||
<Pagination numberOfPages={numberOfPages} pageNumber={pageNumber} />
|
||||
</>
|
||||
);
|
||||
}
|
62
src/app/blog/action.ts
Normal file
62
src/app/blog/action.ts
Normal file
@ -0,0 +1,62 @@
|
||||
"use server";
|
||||
|
||||
import { notFound } from "next/navigation";
|
||||
import { Post, Summary } from "./types";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
export async function getTags(): Promise<string[]> {
|
||||
const prisma = new PrismaClient();
|
||||
const tags = (await prisma.tag.findMany({ select: { name: true } })).map(
|
||||
(tag) => tag.name
|
||||
);
|
||||
return tags;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
export async function getPost(slug: string): Promise<Post | null> {
|
||||
const primsa = new PrismaClient();
|
||||
const result = await primsa.post.findUnique({
|
||||
where: { slug: slug },
|
||||
include: { tags: { select: { name: true } } },
|
||||
omit: {
|
||||
contentMarkdown: true,
|
||||
},
|
||||
});
|
||||
if (result == null) {
|
||||
notFound();
|
||||
}
|
||||
const post = {
|
||||
...result,
|
||||
tags: result.tags.map((tag) => tag.name),
|
||||
contentRendered: result.contentRendered as { __html: string },
|
||||
contentMarkdown: undefined,
|
||||
};
|
||||
return post;
|
||||
}
|
||||
|
||||
export async function getSummaries(
|
||||
pageNumber: number,
|
||||
tagFilter: string | null = null
|
||||
): Promise<{ metadata: Summary[]; numberOfPages: number }> {
|
||||
const prisma = new PrismaClient();
|
||||
let filter = {};
|
||||
if (tagFilter) {
|
||||
filter = { where: { tags: { some: { name: { contains: tagFilter } } } } };
|
||||
}
|
||||
const numberOfPosts = await prisma.post.count(filter);
|
||||
const numberOfPages = Math.ceil(numberOfPosts / PAGE_SIZE);
|
||||
const posts = (
|
||||
await prisma.post.findMany({
|
||||
...filter,
|
||||
omit: { contentMarkdown: true, contentRendered: true },
|
||||
include: { tags: { select: { name: true } } },
|
||||
orderBy: { publishedDate: "desc" },
|
||||
skip: PAGE_SIZE * (pageNumber - 1),
|
||||
take: PAGE_SIZE,
|
||||
})
|
||||
).map((obj) => {
|
||||
return { ...obj, tags: obj.tags.map((tag) => tag.name) };
|
||||
});
|
||||
return { metadata: posts, numberOfPages: numberOfPages };
|
||||
}
|
21
src/app/blog/error.tsx
Normal file
21
src/app/blog/error.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
"use client"; // Error boundaries must be Client Components
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function Error({
|
||||
error,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
// Log the error to an error reporting service
|
||||
console.error(error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Something went wrong!</h2>
|
||||
</div>
|
||||
);
|
||||
}
|
74
src/app/blog/login/Form.tsx
Normal file
74
src/app/blog/login/Form.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import Form from "next/form";
|
||||
import { handleLogin } from "./action";
|
||||
|
||||
export default function FormComponent() {
|
||||
return (
|
||||
<Form action={handleLogin}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "1rem",
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", flexDirection: "column" }}>
|
||||
Username:
|
||||
<input
|
||||
type="text"
|
||||
style={{
|
||||
height: "2rem",
|
||||
backgroundColor: "#333",
|
||||
borderStyle: "none",
|
||||
color: "#eee",
|
||||
fontSize: 16,
|
||||
padding: "0.5rem 1rem",
|
||||
}}
|
||||
name="username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column" }}>
|
||||
Password:
|
||||
<input
|
||||
type="password"
|
||||
style={{
|
||||
height: "2rem",
|
||||
backgroundColor: "#333",
|
||||
borderStyle: "none",
|
||||
color: "#eee",
|
||||
fontSize: 16,
|
||||
padding: "0.5rem 1rem",
|
||||
}}
|
||||
name="password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
style={{
|
||||
backgroundColor: "#333",
|
||||
borderStyle: "none",
|
||||
color: "#eee",
|
||||
fontSize: "1rem",
|
||||
padding: "0.5rem 1rem",
|
||||
transition: "background-color 0.3s linear",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onMouseOver={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "#444";
|
||||
}}
|
||||
onMouseOut={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "#333";
|
||||
}}
|
||||
type="submit"
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
27
src/app/blog/login/action.ts
Normal file
27
src/app/blog/login/action.ts
Normal file
@ -0,0 +1,27 @@
|
||||
"use server";
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import argon2 from "argon2";
|
||||
import { setSession } from "../write/auth";
|
||||
import { redirect, RedirectType } from "next/navigation";
|
||||
|
||||
export async function handleLogin(data: FormData) {
|
||||
const prisma = new PrismaClient();
|
||||
const username = data.get("username")?.toString();
|
||||
const password = data.get("password")?.toString();
|
||||
if (!username || !password) {
|
||||
throw new Error("Missing username or password");
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { username } });
|
||||
if (!user) {
|
||||
redirect("/blog/login?error=Invalid%20credentials", RedirectType.replace);
|
||||
}
|
||||
|
||||
if (await argon2.verify(user.password, password)) {
|
||||
setSession();
|
||||
redirect("/blog/write", RedirectType.replace);
|
||||
} else {
|
||||
redirect("/blog/login?error=Invalid%20credentials", RedirectType.replace);
|
||||
}
|
||||
}
|
12
src/app/blog/login/page.tsx
Normal file
12
src/app/blog/login/page.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { isLoggedIn } from "../write/auth";
|
||||
import FormComponent from "./Form";
|
||||
|
||||
export default async function Login() {
|
||||
if (await isLoggedIn()) {
|
||||
redirect("/blog/write");
|
||||
}
|
||||
return <FormComponent />;
|
||||
}
|
117
src/app/blog/post/[slug]/PostDisplay.tsx
Normal file
117
src/app/blog/post/[slug]/PostDisplay.tsx
Normal file
@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import * as Types from "../../types";
|
||||
import "highlight.js/styles/github-dark.css";
|
||||
|
||||
export default function PostDisplay({ post }: { post: Types.Post }) {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
borderColor: "#999",
|
||||
transition: "border-color 0.3s linear",
|
||||
borderStyle: "solid",
|
||||
marginTop: "1rem",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
backgroundColor: "#999",
|
||||
color: "#111",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: "0.5rem",
|
||||
boxSizing: "border-box",
|
||||
fontSize: "1rem",
|
||||
}}
|
||||
>
|
||||
<span>{post.title}</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
fontSize: "0.9rem",
|
||||
maxWidth: "100%",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-start",
|
||||
padding: "0.5rem",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
tags: {post.tags.join(", ")}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
padding: "0.5rem",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<span>Published: </span>
|
||||
<span>{post.publishedDate.toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "2px",
|
||||
backgroundColor: "#999",
|
||||
}}
|
||||
></div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
padding: "1rem 0.5rem 0 0.5rem",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: "0.9rem" }}>
|
||||
<div dangerouslySetInnerHTML={post.contentRendered} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
marginTop: "1rem",
|
||||
userSelect: "none",
|
||||
cursor: "pointer",
|
||||
backgroundColor: "transparent",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
backgroundColor: "#999",
|
||||
color: "#111",
|
||||
fontSize: "0.8rem",
|
||||
padding: "0.5rem",
|
||||
transition: "background-color 0.3s linear",
|
||||
}}
|
||||
onMouseOver={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "#eee";
|
||||
}}
|
||||
onMouseOut={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "#999";
|
||||
}}
|
||||
onClick={() => router.push("/blog")}
|
||||
>
|
||||
return to all posts
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
20
src/app/blog/post/[slug]/page.tsx
Normal file
20
src/app/blog/post/[slug]/page.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getPost } from "../../action";
|
||||
import PostDisplay from "./PostDisplay";
|
||||
|
||||
export default async function Post({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const post = await getPost(slug);
|
||||
if (!post) {
|
||||
notFound();
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<PostDisplay post={post} />
|
||||
</>
|
||||
);
|
||||
}
|
16
src/app/blog/types.ts
Normal file
16
src/app/blog/types.ts
Normal file
@ -0,0 +1,16 @@
|
||||
export interface PostBase {
|
||||
title: string;
|
||||
publishedDate: Date;
|
||||
slug: string;
|
||||
tags: string[];
|
||||
is_draft: boolean;
|
||||
}
|
||||
|
||||
export interface Summary extends PostBase {
|
||||
blurb: string;
|
||||
}
|
||||
|
||||
export interface Post extends PostBase {
|
||||
contentMarkdown: string | undefined;
|
||||
contentRendered: { __html: string } | undefined;
|
||||
}
|
120
src/app/blog/write/ContentArea.tsx
Normal file
120
src/app/blog/write/ContentArea.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
import MarkdownIt from "markdown-it";
|
||||
import hljs from "highlight.js";
|
||||
import "highlight.js/styles/github-dark.css";
|
||||
import { KeyboardEvent } from "react";
|
||||
|
||||
const md = new MarkdownIt({
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
highlight: (str, lang) => {
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
try {
|
||||
return hljs.highlight(str, { language: lang }).value;
|
||||
} catch {}
|
||||
}
|
||||
return "";
|
||||
},
|
||||
});
|
||||
|
||||
export default function ContentArea({
|
||||
content,
|
||||
setContent,
|
||||
}: {
|
||||
content: string;
|
||||
setContent: (e: string) => void;
|
||||
}) {
|
||||
const renderedContent =
|
||||
content.trim() != ""
|
||||
? { __html: md.render(content) }
|
||||
: { __html: "content is previewed here!" };
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
fontSize: 14,
|
||||
marginTop: "1rem",
|
||||
}}
|
||||
>
|
||||
<span>content:</span>
|
||||
<textarea
|
||||
style={{
|
||||
resize: "none",
|
||||
width: "100%",
|
||||
height: "10rem",
|
||||
background: "#333",
|
||||
borderStyle: "none",
|
||||
color: "#eee",
|
||||
}}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
handleTabbing(e);
|
||||
setContent(e.currentTarget.value);
|
||||
}}
|
||||
value={content}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
border: "1px solid #999",
|
||||
width: "100%",
|
||||
padding: "0.5rem",
|
||||
boxSizing: "border-box",
|
||||
fontSize: "0.8rem",
|
||||
}}
|
||||
dangerouslySetInnerHTML={renderedContent}
|
||||
></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function handleTabbing(e: KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if (e.key == "Tab") {
|
||||
e.preventDefault();
|
||||
const start = e.currentTarget.selectionStart;
|
||||
const end = e.currentTarget.selectionEnd;
|
||||
const value = e.currentTarget.value;
|
||||
if (e.shiftKey) {
|
||||
const currentLineStart = value.lastIndexOf("\n", start - 1) + 1;
|
||||
const currentLineEnd = value.indexOf("\n", start);
|
||||
const currentLine = value.substring(
|
||||
currentLineStart,
|
||||
currentLineEnd === -1 ? value.length : currentLineEnd
|
||||
);
|
||||
const numSpaces = currentLine.search(/\S|$/);
|
||||
let newSpaces = numSpaces;
|
||||
if (numSpaces % 4 === 0) {
|
||||
newSpaces = Math.max(0, numSpaces - 4);
|
||||
} else {
|
||||
newSpaces = Math.max(0, numSpaces - (numSpaces % 4));
|
||||
}
|
||||
const newLine = " ".repeat(newSpaces) + currentLine.trimStart();
|
||||
const newValue =
|
||||
value.substring(0, currentLineStart) +
|
||||
newLine +
|
||||
value.substring(currentLineEnd === -1 ? value.length : currentLineEnd);
|
||||
e.currentTarget.value = newValue;
|
||||
e.currentTarget.selectionStart = e.currentTarget.selectionEnd =
|
||||
currentLineStart + newSpaces;
|
||||
} else {
|
||||
e.currentTarget.value =
|
||||
value.substring(0, start) + " " + value.substring(end, value.length);
|
||||
e.currentTarget.selectionStart = e.currentTarget.selectionEnd = start + 4;
|
||||
}
|
||||
} else if (e.key == "Enter") {
|
||||
e.preventDefault();
|
||||
const start = e.currentTarget.selectionStart;
|
||||
const value = e.currentTarget.value;
|
||||
const beforeCursor = value.substring(0, start);
|
||||
const afterCursor = value.substring(start);
|
||||
const lastLineStart = beforeCursor.lastIndexOf("\n") + 1;
|
||||
const currentLine = beforeCursor.substring(lastLineStart);
|
||||
const indentMatch = currentLine.match(/^(\s*)/);
|
||||
const indent = indentMatch ? indentMatch[1] : "";
|
||||
|
||||
// Add new line with same indentation
|
||||
e.currentTarget.value = beforeCursor + "\n" + indent + afterCursor;
|
||||
e.currentTarget.selectionStart = e.currentTarget.selectionEnd =
|
||||
start + 1 + indent.length;
|
||||
}
|
||||
}
|
114
src/app/blog/write/Write.tsx
Normal file
114
src/app/blog/write/Write.tsx
Normal file
@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import ContentArea from "./ContentArea";
|
||||
import { Post } from "../types";
|
||||
import { savePostServer } from "./action";
|
||||
|
||||
export default function Write({ post }: { post?: Post }) {
|
||||
const [title, setTitle] = useState(post?.title ?? "");
|
||||
const [tagsString, setTagsString] = useState<string>(
|
||||
post?.tags.join(", ") ?? ""
|
||||
);
|
||||
const tags = tagsString.split(",").map((tag) => tag.trim());
|
||||
const [content, setContent] = useState(post?.contentMarkdown ?? "");
|
||||
|
||||
const enabled =
|
||||
title.trim() !== "" && content.trim() !== "" && tags.length > 0;
|
||||
|
||||
const savePost = async (is_draft: boolean) => {
|
||||
if (!enabled) return;
|
||||
await savePostServer(title, content, tags, is_draft, post?.slug);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ marginBottom: "1rem" }}>write your post</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
<span>title:</span>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
style={{
|
||||
borderStyle: "none",
|
||||
backgroundColor: "#333",
|
||||
color: "#eee",
|
||||
fontSize: 16,
|
||||
padding: 10,
|
||||
}}
|
||||
/>
|
||||
<span>tags: (comma separated)</span>
|
||||
<input
|
||||
type="text"
|
||||
value={tagsString}
|
||||
onChange={(e) => setTagsString(e.target.value)}
|
||||
style={{
|
||||
borderStyle: "none",
|
||||
backgroundColor: "#333",
|
||||
color: "#eee",
|
||||
fontSize: 15,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ContentArea content={content} setContent={setContent} />
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-evenly",
|
||||
marginTop: "1rem",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: "0.5rem 1rem",
|
||||
backgroundColor: enabled ? "#333" : "#222",
|
||||
userSelect: "none",
|
||||
cursor: enabled ? "pointer" : "default",
|
||||
transition: "background-color 0.3s linear",
|
||||
}}
|
||||
onMouseOver={(e) => {
|
||||
if (!enabled) return;
|
||||
e.currentTarget.style.backgroundColor = "#444";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!enabled) return;
|
||||
e.currentTarget.style.backgroundColor = "#333";
|
||||
}}
|
||||
onClick={() => savePost(true)}
|
||||
>
|
||||
save as draft
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
padding: "0.5rem 1rem",
|
||||
backgroundColor: enabled ? "#353" : "#222",
|
||||
userSelect: "none",
|
||||
cursor: enabled ? "pointer" : "default",
|
||||
transition: "background-color 0.3s linear",
|
||||
}}
|
||||
onMouseOver={(e) => {
|
||||
if (!enabled) return;
|
||||
e.currentTarget.style.backgroundColor = "#464";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!enabled) return;
|
||||
e.currentTarget.style.backgroundColor = "#353";
|
||||
}}
|
||||
onClick={() => savePost(false)}
|
||||
>
|
||||
publish
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
25
src/app/blog/write/[[...slug]]/page.tsx
Normal file
25
src/app/blog/write/[[...slug]]/page.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getPost } from "../../action";
|
||||
import { Post } from "../../types";
|
||||
import Write from "../Write";
|
||||
import { isLoggedIn } from "../auth";
|
||||
|
||||
export default async function WritePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string[] | undefined }>;
|
||||
}) {
|
||||
if (!(await isLoggedIn())) {
|
||||
redirect("/blog/login");
|
||||
}
|
||||
|
||||
const slug = (await params).slug?.[0];
|
||||
let post: Post | undefined = undefined;
|
||||
if (slug) {
|
||||
post = (await getPost(slug)) ?? undefined;
|
||||
if (post == null) {
|
||||
notFound();
|
||||
}
|
||||
}
|
||||
return <Write post={post} />;
|
||||
}
|
58
src/app/blog/write/action.ts
Normal file
58
src/app/blog/write/action.ts
Normal file
@ -0,0 +1,58 @@
|
||||
"use server";
|
||||
import { redirect } from "next/navigation";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import MarkdownIt from "markdown-it";
|
||||
import hljs from "highlight.js";
|
||||
import * as cheerio from "cheerio";
|
||||
import slugify from "slugify";
|
||||
|
||||
export async function savePostServer(
|
||||
title: string,
|
||||
contentMarkdown: string,
|
||||
tags: string[],
|
||||
is_draft: boolean,
|
||||
existingSlug?: string
|
||||
) {
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const md = new MarkdownIt({
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
highlight: (str, lang) => {
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
try {
|
||||
return hljs.highlight(str, { language: lang }).value;
|
||||
} catch {}
|
||||
}
|
||||
return "";
|
||||
},
|
||||
});
|
||||
|
||||
const contentRendered = { __html: md.render(contentMarkdown) };
|
||||
const blurb = cheerio.load(contentRendered.__html).text().substring(0, 200);
|
||||
const slug = slugify(title, { lower: true, strict: true });
|
||||
|
||||
if (existingSlug) {
|
||||
await prisma.post.delete({ where: { slug: existingSlug } });
|
||||
}
|
||||
|
||||
await prisma.post.create({
|
||||
data: {
|
||||
title,
|
||||
slug,
|
||||
publishedDate: new Date(),
|
||||
tags: {
|
||||
connectOrCreate: tags.map((tag) => ({
|
||||
where: { name: tag },
|
||||
create: { name: tag },
|
||||
})),
|
||||
},
|
||||
blurb,
|
||||
contentMarkdown,
|
||||
contentRendered,
|
||||
is_draft,
|
||||
},
|
||||
});
|
||||
|
||||
redirect("/blog");
|
||||
}
|
50
src/app/blog/write/auth.ts
Normal file
50
src/app/blog/write/auth.ts
Normal file
@ -0,0 +1,50 @@
|
||||
"use server";
|
||||
|
||||
import { jwtVerify, SignJWT } from "jose";
|
||||
import { cookies } from "next/headers";
|
||||
const SECRET_KEY = process.env.SESSION_SECRET;
|
||||
const encodedKey = new TextEncoder().encode(SECRET_KEY);
|
||||
|
||||
export type SessionPayload = { admin: true };
|
||||
|
||||
export async function encrypt(payload: SessionPayload) {
|
||||
return new SignJWT(payload)
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime("7d")
|
||||
.sign(encodedKey);
|
||||
}
|
||||
|
||||
export async function decrypt(
|
||||
token: string | undefined = ""
|
||||
): Promise<SessionPayload | null> {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, encodedKey, {
|
||||
algorithms: ["HS256"],
|
||||
});
|
||||
return payload as SessionPayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function isLoggedIn() {
|
||||
const cookieStore = (await cookies()).get("session")?.value;
|
||||
const session = await decrypt(cookieStore);
|
||||
if (session != null && session.admin) {
|
||||
setSession();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function setSession() {
|
||||
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
||||
(await cookies()).set("session", await encrypt({ admin: true }), {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
expires: expiresAt,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
});
|
||||
}
|
85
src/app/contact/page.tsx
Normal file
85
src/app/contact/page.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
/* eslint-disable react/no-unescaped-entities */
|
||||
|
||||
"use client";
|
||||
|
||||
import SubmitContact from "@/components/contact";
|
||||
import { useActionState } from "react";
|
||||
import ReCAPTCHA from "react-google-recaptcha";
|
||||
|
||||
const RECAPTCHA_SITE_KEY = process.env.RECAPTCHA_SITE_KEY;
|
||||
|
||||
export default function Contact() {
|
||||
const inputStyle = {
|
||||
backgroundColor: "#111",
|
||||
color: "#eee",
|
||||
borderWidth: 0,
|
||||
padding: "1rem",
|
||||
};
|
||||
const [state, formAction, pending] = useActionState(SubmitContact, null);
|
||||
|
||||
if (!RECAPTCHA_SITE_KEY) {
|
||||
throw new Error("ReCAPTCHA not configured correctly");
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
Need to get in touch?
|
||||
<br />
|
||||
<br />
|
||||
<form
|
||||
action={formAction}
|
||||
style={{ display: "flex", flexDirection: "column", gap: "1rem" }}
|
||||
>
|
||||
<input
|
||||
style={inputStyle}
|
||||
type="text"
|
||||
name="name"
|
||||
placeholder="Your Name"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
style={inputStyle}
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="Your Email"
|
||||
required
|
||||
/>
|
||||
<textarea
|
||||
style={{ ...inputStyle, resize: "none", height: "5rem" }}
|
||||
name="message"
|
||||
placeholder="Your Message"
|
||||
required
|
||||
></textarea>
|
||||
{state && (
|
||||
<span
|
||||
style={{
|
||||
padding: "1rem",
|
||||
backgroundColor: "error" in state ? "#f003" : "#0f03",
|
||||
}}
|
||||
>
|
||||
{"error" in state && state.error}
|
||||
{"success" in state && "Submitted successfully!"}
|
||||
</span>
|
||||
)}
|
||||
<ReCAPTCHA sitekey={RECAPTCHA_SITE_KEY} theme="dark" />
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
...inputStyle,
|
||||
backgroundColor: pending ? "#333" : "#111",
|
||||
cursor: pending ? "default" : "pointer",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!pending) e.currentTarget.style.backgroundColor = "#333";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!pending) e.currentTarget.style.backgroundColor = "#111";
|
||||
}}
|
||||
disabled={pending}
|
||||
>
|
||||
{pending ? "Sending..." : "Submit"}
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
6
src/app/globals.css
Normal file
6
src/app/globals.css
Normal file
@ -0,0 +1,6 @@
|
||||
body{
|
||||
background-color: #222;
|
||||
color: #eee;
|
||||
margin: 0;
|
||||
padding:0;
|
||||
}
|
49
src/app/layout.tsx
Normal file
49
src/app/layout.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import Title from "@/components/Title";
|
||||
import Navbar from "@/components/Navbar";
|
||||
import { bodyFont } from "@/components/fonts";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "nrx.sh",
|
||||
description: "naresh's site",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<Title />
|
||||
|
||||
<Navbar />
|
||||
<div
|
||||
className={bodyFont.className}
|
||||
style={{
|
||||
backgroundColor: "#1D1D1D",
|
||||
marginTop: "2rem",
|
||||
width: "600px",
|
||||
maxWidth: "100vw",
|
||||
padding: "2rem",
|
||||
boxSizing: "border-box",
|
||||
fontSize: "1.1rem",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
65
src/app/links/page.tsx
Normal file
65
src/app/links/page.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
/* eslint-disable react/no-unescaped-entities */
|
||||
|
||||
"use client";
|
||||
|
||||
import { SiGitea } from "@icons-pack/react-simple-icons";
|
||||
import { NotebookText } from "lucide-react";
|
||||
import { SocialIcon } from "react-social-icons";
|
||||
|
||||
export default function Links() {
|
||||
return (
|
||||
<>
|
||||
here are some of my links:
|
||||
<br />
|
||||
<br />
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "1rem",
|
||||
}}
|
||||
>
|
||||
<Link href="https://blog.nrx.sh">
|
||||
<NotebookText size={26} style={{ padding: 10 }} /> My Blog {"<3"}
|
||||
</Link>
|
||||
<Link href="https://github.com/naresh97">
|
||||
<SocialIcon network="github" bgColor="#111" /> GitHub
|
||||
</Link>
|
||||
<Link href="https://linkedin.com/in/nareshkumar-rao">
|
||||
<SocialIcon network="linkedin" bgColor="#111" /> LinkedIn
|
||||
</Link>
|
||||
<Link href="https://git.nrx.sh">
|
||||
<SiGitea size={40} /> Private Git Repo
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Link({ href, children }: { href: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "10px",
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
backgroundColor: "#111",
|
||||
padding: "1rem",
|
||||
transition: "background-color 0.3s ease",
|
||||
borderRadius: "0.3rem",
|
||||
cursor: "pointer",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "#333";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "#111";
|
||||
}}
|
||||
onClick={() => window.open(href, "_blank")}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
34
src/app/not-found.tsx
Normal file
34
src/app/not-found.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
/* eslint-disable react/no-unescaped-entities */
|
||||
|
||||
import { titleFont } from "@/components/fonts";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<>
|
||||
<span style={{ fontSize: 32 }} className={titleFont.className}>
|
||||
404
|
||||
</span>
|
||||
<br />
|
||||
<br />
|
||||
shit... something is fucked and now you're here
|
||||
<br />
|
||||
i'll look into it, at some point
|
||||
<br />
|
||||
but for now, maybe you'd like to
|
||||
<br />
|
||||
<br />
|
||||
<Link
|
||||
href="/"
|
||||
style={{
|
||||
textDecoration: "none",
|
||||
backgroundColor: "#aaa",
|
||||
color: "#222",
|
||||
padding: "0.5rem",
|
||||
}}
|
||||
>
|
||||
return home?
|
||||
</Link>
|
||||
</>
|
||||
);
|
||||
}
|
20
src/app/page.tsx
Normal file
20
src/app/page.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
/* eslint-disable react/no-unescaped-entities */
|
||||
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<>
|
||||
hi,
|
||||
<br />
|
||||
<br /> thanks for stopping by.
|
||||
<br />
|
||||
<br /> this is still a work in progress, so it's a little sparse.
|
||||
<br />
|
||||
<br />
|
||||
<br /> naresh.
|
||||
</>
|
||||
);
|
||||
}
|
59
src/components/Navbar.tsx
Normal file
59
src/components/Navbar.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import React from "react";
|
||||
import { navBarFont } from "./fonts";
|
||||
import {
|
||||
PAGES,
|
||||
Pages,
|
||||
pathNameFromSelectedPage,
|
||||
selectedPageFromPathName,
|
||||
} from "./pages";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
|
||||
export default function Navbar() {
|
||||
const [hoveredPage, setHoveredPage] = useState<Pages | null>(null);
|
||||
|
||||
const pathName = usePathname();
|
||||
const selectedPage = selectedPageFromPathName(pathName);
|
||||
const router = useRouter();
|
||||
|
||||
const navbarItem = (page: Pages): React.CSSProperties => ({
|
||||
padding: "0.3rem 0.7rem",
|
||||
cursor: "pointer",
|
||||
backgroundColor:
|
||||
selectedPage == page || hoveredPage === page ? "#eee" : "transparent",
|
||||
color: selectedPage == page || hoveredPage == page ? "#222" : "#eee",
|
||||
transition: "all 0.3s ease",
|
||||
});
|
||||
|
||||
return (
|
||||
<nav
|
||||
className={navBarFont.className}
|
||||
style={{
|
||||
fontWeight: "700",
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
gap: "1rem",
|
||||
fontSize: "1.1rem",
|
||||
flexWrap: "wrap",
|
||||
maxWidth: "90vw",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{PAGES.map((page, index) => (
|
||||
<React.Fragment key={page}>
|
||||
<div
|
||||
style={navbarItem(page)}
|
||||
onClick={() => router.push(pathNameFromSelectedPage(page))}
|
||||
onMouseEnter={() => setHoveredPage(page)}
|
||||
onMouseLeave={() => setHoveredPage(null)}
|
||||
>
|
||||
<div style={{ userSelect: "none" }}>{page}</div>
|
||||
</div>
|
||||
{index < PAGES.length - 1 && <span>|</span>}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
5
src/components/Title.tsx
Normal file
5
src/components/Title.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { titleFont } from "./fonts";
|
||||
|
||||
export default function Title() {
|
||||
return <h1 className={titleFont.className}>nrx.sh</h1>;
|
||||
}
|
76
src/components/contact.ts
Normal file
76
src/components/contact.ts
Normal file
@ -0,0 +1,76 @@
|
||||
"use server";
|
||||
|
||||
import { createTransport } from "nodemailer";
|
||||
|
||||
const RECAPTCHA_SECRET_KEY = process.env.RECAPTCHA_SECRET_KEY;
|
||||
|
||||
export type SubmitContactReturn = { error: string } | { success: true };
|
||||
|
||||
export default async function SubmitContact(
|
||||
prevState: unknown,
|
||||
data: FormData
|
||||
): Promise<SubmitContactReturn> {
|
||||
if (!RECAPTCHA_SECRET_KEY) {
|
||||
console.error(
|
||||
"RECAPTCHA_SECRET_KEY is not set. Please check your environment variables."
|
||||
);
|
||||
throw new Error("Server error: RECAPTCHA not configure correctly.");
|
||||
}
|
||||
|
||||
const name = data.get("name");
|
||||
const email = data.get("email");
|
||||
const message = data.get("message");
|
||||
const recaptcha = data.get("g-recaptcha-response");
|
||||
|
||||
if (!name || !email || !message) {
|
||||
return { error: "All fields are required." };
|
||||
}
|
||||
|
||||
if (!recaptcha) {
|
||||
return { error: "Please complete the reCAPTCHA." };
|
||||
}
|
||||
|
||||
try {
|
||||
const recaptchaResponse = await fetch(
|
||||
`https://www.google.com/recaptcha/api/siteverify`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: `secret=${RECAPTCHA_SECRET_KEY}&response=${recaptcha}`,
|
||||
}
|
||||
);
|
||||
const recaptchaData = await recaptchaResponse.json();
|
||||
if (!recaptchaData.success) {
|
||||
return { error: "reCAPTCHA verification failed." };
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return { error: "Could not reach reCAPTCHA for verification." };
|
||||
}
|
||||
|
||||
try {
|
||||
const transporter = createTransport({
|
||||
host: process.env.MAIL_HOST ?? "localhost",
|
||||
port: process.env.MAIL_PORT ? parseInt(process.env.MAIL_PORT) : undefined,
|
||||
secure: true,
|
||||
auth: {
|
||||
user: process.env.MAIL_USER,
|
||||
pass: process.env.MAIL_PASS,
|
||||
},
|
||||
});
|
||||
await transporter.sendMail({
|
||||
from: process.env.MAIL_FROM,
|
||||
to: process.env.MAIL_TO,
|
||||
subject: `From ${name}`,
|
||||
replyTo: email.toString(),
|
||||
text: `Name:${name}\nEmail: ${email}\nMessage:\n ${message}`,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return { error: "Failed to send email." };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
15
src/components/fonts.ts
Normal file
15
src/components/fonts.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { Doto, Sixtyfour, Space_Grotesk } from "next/font/google";
|
||||
|
||||
export const titleFont = Sixtyfour({
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const navBarFont = Doto({
|
||||
subsets: ["latin"],
|
||||
weight: "900",
|
||||
});
|
||||
|
||||
export const bodyFont = Space_Grotesk({
|
||||
subsets: ["latin"],
|
||||
weight: "400",
|
||||
});
|
19
src/components/pages.ts
Normal file
19
src/components/pages.ts
Normal file
@ -0,0 +1,19 @@
|
||||
export type Pages = "home" | "about" | "links" | "contact" | "blog";
|
||||
export const PAGES: Pages[] = ["home", "about", "blog", "links", "contact"];
|
||||
|
||||
export function selectedPageFromPathName(pathName: string): Pages {
|
||||
if (pathName === "/") {
|
||||
return "home";
|
||||
}
|
||||
if (pathName.includes("/blog/")) {
|
||||
return "blog";
|
||||
}
|
||||
return pathName.replace("/", "") as Pages;
|
||||
}
|
||||
|
||||
export function pathNameFromSelectedPage(page: Pages): string {
|
||||
if (page === "home") {
|
||||
return "/";
|
||||
}
|
||||
return `/${page}`;
|
||||
}
|
27
tsconfig.json
Normal file
27
tsconfig.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user