Compare commits
27 Commits
35efde0db9
...
main
Author | SHA1 | Date | |
---|---|---|---|
614adcb9f8 | |||
a5b8e40761 | |||
e56bb8ac1c | |||
d9e9824146 | |||
8b46bf354e | |||
86ec00de4c | |||
49917e1eb0 | |||
edc575b153 | |||
f28c2defc4 | |||
42c8a9e28a | |||
5a3ce73ce8 | |||
82ce4b345a | |||
f640afbcb1 | |||
952d9541b9 | |||
a223fc709e | |||
64e5fd3354 | |||
434536d1f2 | |||
4656df7fca | |||
c96525e1fc | |||
4406de6986 | |||
5c139e83f5 | |||
3fc41e6fc4 | |||
b1422269bb | |||
dcde0cb893 | |||
4d5b1692c4 | |||
20da33d412 | |||
f2df5c4f75 |
@ -16,6 +16,7 @@ RUN cp -r .next/static .next/standalone/.next/
|
||||
|
||||
FROM node:current-alpine AS production
|
||||
COPY --from=build /app/.next/standalone /app
|
||||
COPY --from=build /app/prisma /app/prisma
|
||||
EXPOSE 3000
|
||||
WORKDIR /app
|
||||
CMD ["node", "server.js"]
|
||||
CMD ["/bin/sh", "-c", "npx --yes prisma migrate deploy && node server.js"]
|
||||
|
149
package-lock.json
generated
149
package-lock.json
generated
@ -13,10 +13,9 @@
|
||||
"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",
|
||||
"next-auth": "^5.0.0-beta.25",
|
||||
"nodemailer": "^6.10.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@ -38,6 +37,46 @@
|
||||
"typescript": "^5"
|
||||
}
|
||||
},
|
||||
"node_modules/@auth/core": {
|
||||
"version": "0.37.2",
|
||||
"resolved": "https://registry.npmjs.org/@auth/core/-/core-0.37.2.tgz",
|
||||
"integrity": "sha512-kUvzyvkcd6h1vpeMAojK2y7+PAV5H+0Cc9+ZlKYDFhDY31AlvsB+GW5vNO4qE3Y07KeQgvNO9U0QUx/fN62kBw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@panva/hkdf": "^1.2.1",
|
||||
"@types/cookie": "0.6.0",
|
||||
"cookie": "0.7.1",
|
||||
"jose": "^5.9.3",
|
||||
"oauth4webapi": "^3.0.0",
|
||||
"preact": "10.11.3",
|
||||
"preact-render-to-string": "5.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@simplewebauthn/browser": "^9.0.1",
|
||||
"@simplewebauthn/server": "^9.0.2",
|
||||
"nodemailer": "^6.8.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@simplewebauthn/browser": {
|
||||
"optional": true
|
||||
},
|
||||
"@simplewebauthn/server": {
|
||||
"optional": true
|
||||
},
|
||||
"nodemailer": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@auth/core/node_modules/jose": {
|
||||
"version": "5.10.0",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz",
|
||||
"integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz",
|
||||
@ -862,6 +901,15 @@
|
||||
"node": ">=12.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@panva/hkdf": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
|
||||
"integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/@phc/format": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz",
|
||||
@ -933,6 +981,12 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cookie": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
|
||||
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
|
||||
@ -2028,6 +2082,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
|
||||
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@ -3875,15 +3938,6 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "6.0.10",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.0.10.tgz",
|
||||
"integrity": "sha512-skIAxZqcMkOrSwjJvplIPYrlXGpxTPnro2/QWTDCxAdWQrSTV5/KqspMWmi5WAx5+ULswASJiZ0a+1B/Lxt9cw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@ -4041,15 +4095,6 @@
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.485.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.485.0.tgz",
|
||||
"integrity": "sha512-NvyQJ0LKyyCxL23nPKESlr/jmz8r7fJO1bkuptSNYSy0s8VVj4ojhX0YAgmE1e0ewfxUZjIlZpvH+otfTnla8Q==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-it": {
|
||||
"version": "14.1.0",
|
||||
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
|
||||
@ -4216,6 +4261,33 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/next-auth": {
|
||||
"version": "5.0.0-beta.25",
|
||||
"resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.25.tgz",
|
||||
"integrity": "sha512-2dJJw1sHQl2qxCrRk+KTQbeH+izFbGFPuJj5eGgBZFYyiYYtvlrBeUw1E/OJJxTRjuxbSYGnCTkUIRsIIW0bog==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@auth/core": "0.37.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@simplewebauthn/browser": "^9.0.1",
|
||||
"@simplewebauthn/server": "^9.0.2",
|
||||
"next": "^14.0.0-0 || ^15.0.0-0",
|
||||
"nodemailer": "^6.6.5",
|
||||
"react": "^18.2.0 || ^19.0.0-0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@simplewebauthn/browser": {
|
||||
"optional": true
|
||||
},
|
||||
"@simplewebauthn/server": {
|
||||
"optional": true
|
||||
},
|
||||
"nodemailer": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "8.3.1",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.3.1.tgz",
|
||||
@ -4257,6 +4329,15 @@
|
||||
"url": "https://github.com/fb55/nth-check?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/oauth4webapi": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.4.0.tgz",
|
||||
"integrity": "sha512-5lcbectYuzQHvh0Ni7Epvc13sMVq7BxWUlHEYHaNko64OA1hcats0Huq30vZjqCZULcVE/PZxAGGPansfRAWKQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
@ -4581,6 +4662,28 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/preact": {
|
||||
"version": "10.11.3",
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz",
|
||||
"integrity": "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/preact"
|
||||
}
|
||||
},
|
||||
"node_modules/preact-render-to-string": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.3.tgz",
|
||||
"integrity": "sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pretty-format": "^3.8.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"preact": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
@ -4591,6 +4694,12 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-format": {
|
||||
"version": "3.8.0",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz",
|
||||
"integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
|
@ -14,10 +14,9 @@
|
||||
"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",
|
||||
"next-auth": "^5.0.0-beta.25",
|
||||
"nodemailer": "^6.10.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
@ -0,0 +1,7 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Clipboard" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "Clipboard_pkey" PRIMARY KEY ("id")
|
||||
);
|
@ -30,3 +30,8 @@ model User {
|
||||
username String @unique
|
||||
password String
|
||||
}
|
||||
|
||||
model Clipboard {
|
||||
id Int @id @default(autoincrement())
|
||||
content String @db.Text
|
||||
}
|
||||
|
@ -1,16 +1,14 @@
|
||||
/* eslint-disable react/no-unescaped-entities */
|
||||
|
||||
export default function About() {
|
||||
return (
|
||||
<>
|
||||
so hey, i'm naresh!
|
||||
so hey, i'm naresh!
|
||||
<br />
|
||||
<br />
|
||||
i'm an engineer and i love building things and tinkering on things.
|
||||
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.
|
||||
<br /> umm, maybe i'll write more here later.
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
2
src/app/api/auth/[...nextauth]/route.ts
Normal file
2
src/app/api/auth/[...nextauth]/route.ts
Normal file
@ -0,0 +1,2 @@
|
||||
import { handlers } from "@/auth";
|
||||
export const { GET, POST } = handlers;
|
38
src/app/blog/[[...tag]]/ActionButtons.tsx
Normal file
38
src/app/blog/[[...tag]]/ActionButtons.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function ActionButtons({ loggedIn }: { loggedIn: boolean }) {
|
||||
const router = useRouter();
|
||||
return loggedIn ? (
|
||||
<div
|
||||
style={{
|
||||
marginTop: "2rem",
|
||||
padding: "0.5rem 1rem",
|
||||
backgroundColor: "#333",
|
||||
width: "fit-content",
|
||||
userSelect: "none",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => router.push("/blog/write")}
|
||||
>
|
||||
write a post
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
marginTop: "2rem",
|
||||
padding: "0.3rem 0.5rem",
|
||||
backgroundColor: "#333",
|
||||
width: "fit-content",
|
||||
userSelect: "none",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.8rem",
|
||||
}}
|
||||
onClick={() => signIn()}
|
||||
>
|
||||
login
|
||||
</div>
|
||||
);
|
||||
}
|
@ -26,169 +26,173 @@ export default function PostSummary({
|
||||
};
|
||||
|
||||
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,
|
||||
}}
|
||||
>
|
||||
{metadata.is_draft && (
|
||||
<>
|
||||
<span>draft - </span>
|
||||
<div
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
color: "#999",
|
||||
transition: "color 0.3s linear",
|
||||
}}
|
||||
onMouseOver={(e) => {
|
||||
e.currentTarget.style.color = "#eee";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = "#999";
|
||||
}}
|
||||
onClick={async () => {
|
||||
await publishArticle(metadata.slug);
|
||||
router.refresh();
|
||||
}}
|
||||
>
|
||||
move to published
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!metadata.is_draft && (
|
||||
<>
|
||||
<div>
|
||||
<div>
|
||||
<span>published: </span>
|
||||
<span>{metadata.publishedDate.toLocaleDateString()}</span>
|
||||
</div>
|
||||
{loggedIn && (
|
||||
<div
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
color: "#999",
|
||||
transition: "color 0.3s linear",
|
||||
}}
|
||||
onMouseOver={(e) => {
|
||||
e.currentTarget.style.color = "#eee";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = "#999";
|
||||
}}
|
||||
onClick={async () => {
|
||||
await unpublishArticle(metadata.slug);
|
||||
router.refresh();
|
||||
}}
|
||||
>
|
||||
move to drafts
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "2px",
|
||||
backgroundColor: elementColor,
|
||||
transition: "background-color 0.3s linear",
|
||||
}}
|
||||
></div>
|
||||
<>
|
||||
<div
|
||||
key={metadata.slug}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
padding: "1rem 0.5rem 0 0.5rem",
|
||||
borderColor: elementColor,
|
||||
transition: "border-color 0.3s linear",
|
||||
borderStyle: "solid",
|
||||
marginTop: "1rem",
|
||||
}}
|
||||
>
|
||||
<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
|
||||
<div
|
||||
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",
|
||||
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}
|
||||
>
|
||||
Read more <span style={{ fontSize: "1rem" }}>→</span>
|
||||
</span>
|
||||
<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,
|
||||
}}
|
||||
>
|
||||
{metadata.is_draft && (
|
||||
<>
|
||||
<span>draft - </span>
|
||||
<div
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
color: "#999",
|
||||
transition: "color 0.3s linear",
|
||||
}}
|
||||
onMouseOver={(e) => {
|
||||
e.currentTarget.style.color = "#eee";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = "#999";
|
||||
}}
|
||||
onClick={async () => {
|
||||
await publishArticle(metadata.slug);
|
||||
router.refresh();
|
||||
}}
|
||||
>
|
||||
move to published
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!metadata.is_draft && (
|
||||
<>
|
||||
<div>
|
||||
<div>
|
||||
<span>published: </span>
|
||||
<span>
|
||||
{metadata.publishedDate.toLocaleDateString("en-UK")}
|
||||
</span>
|
||||
</div>
|
||||
{loggedIn && (
|
||||
<div
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
color: "#999",
|
||||
transition: "color 0.3s linear",
|
||||
}}
|
||||
onMouseOver={(e) => {
|
||||
e.currentTarget.style.color = "#eee";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = "#999";
|
||||
}}
|
||||
onClick={async () => {
|
||||
await unpublishArticle(metadata.slug);
|
||||
router.refresh();
|
||||
}}
|
||||
>
|
||||
move to drafts
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -7,7 +7,8 @@ import React from "react";
|
||||
import PostSummary from "./PostSummary";
|
||||
import Pagination from "./Pagination";
|
||||
import TagOverview from "./TagOverview";
|
||||
import { isLoggedIn } from "@/components/auth";
|
||||
import ActionButtons from "./ActionButtons";
|
||||
import { auth } from "@/auth";
|
||||
|
||||
export default async function Blog({
|
||||
params,
|
||||
@ -19,6 +20,7 @@ export default async function Blog({
|
||||
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();
|
||||
}
|
||||
@ -27,10 +29,8 @@ export default async function Blog({
|
||||
pageNumber,
|
||||
currentTag
|
||||
);
|
||||
if (pageNumber > numberOfPages) {
|
||||
notFound();
|
||||
}
|
||||
const loggedIn = await isLoggedIn();
|
||||
|
||||
const loggedIn = (await auth())?.user != null;
|
||||
const tags = await getTags(loggedIn);
|
||||
|
||||
return (
|
||||
@ -45,13 +45,24 @@ export default async function Blog({
|
||||
<span style={{ fontSize: 18 }}>naresh writes</span>
|
||||
<span style={{ fontSize: 12 }}>...occasionally</span>
|
||||
</div>
|
||||
<TagOverview tags={tags} currentTag={currentTag} />
|
||||
{metadata
|
||||
.filter((m) => loggedIn || !m.is_draft)
|
||||
.map((m) => (
|
||||
<PostSummary metadata={m} key={m.slug} loggedIn={loggedIn} />
|
||||
))}
|
||||
<Pagination numberOfPages={numberOfPages} pageNumber={pageNumber} />
|
||||
{pageNumber > numberOfPages ? (
|
||||
<div style={{ marginTop: "1rem" }}>
|
||||
ah fuck...
|
||||
<br /> it seems like i haven't written anything yet
|
||||
<br /> so much for a blog, eh?
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<TagOverview tags={tags} currentTag={currentTag} />
|
||||
{metadata
|
||||
.filter((m) => loggedIn || !m.is_draft)
|
||||
.map((m) => (
|
||||
<PostSummary metadata={m} key={m.slug} loggedIn={loggedIn} />
|
||||
))}
|
||||
<Pagination numberOfPages={numberOfPages} pageNumber={pageNumber} />
|
||||
</>
|
||||
)}
|
||||
<ActionButtons loggedIn={loggedIn} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -57,7 +57,7 @@ export async function getSummaries(
|
||||
...filter,
|
||||
omit: { contentMarkdown: true, contentRendered: true },
|
||||
include: { tags: { select: { name: true } } },
|
||||
orderBy: { publishedDate: "asc" },
|
||||
orderBy: { publishedDate: "desc" },
|
||||
skip: PAGE_SIZE * (pageNumber - 1),
|
||||
take: PAGE_SIZE,
|
||||
})
|
||||
@ -79,3 +79,19 @@ export async function unpublishArticle(slug: string) {
|
||||
const prisma = new PrismaClient();
|
||||
await prisma.post.update({ where: { slug }, data: { is_draft: true } });
|
||||
}
|
||||
|
||||
export async function deleteArticle(slug: string) {
|
||||
const prisma = new PrismaClient();
|
||||
const post = await prisma.post.delete({
|
||||
where: { slug },
|
||||
include: { tags: true },
|
||||
});
|
||||
for (const tag of post.tags) {
|
||||
const postsWithTag = await prisma.post.count({
|
||||
where: { tags: { some: { id: tag.id } } },
|
||||
});
|
||||
if (postsWithTag == 0) {
|
||||
await prisma.tag.delete({ where: { id: tag.id } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,74 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
@ -1,27 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import argon2 from "argon2";
|
||||
import { setSession } from "@/components/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);
|
||||
}
|
||||
}
|
@ -1,12 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { isLoggedIn } from "@/components/auth";
|
||||
import FormComponent from "./Form";
|
||||
|
||||
export default async function Login() {
|
||||
if (await isLoggedIn()) {
|
||||
redirect("/blog/write");
|
||||
}
|
||||
return <FormComponent />;
|
||||
}
|
@ -3,7 +3,8 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import * as Types from "../../types";
|
||||
import "highlight.js/styles/github-dark.css";
|
||||
import { publishArticle, unpublishArticle } from "../../action";
|
||||
import { deleteArticle, publishArticle, unpublishArticle } from "../../action";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function PostDisplay({
|
||||
post,
|
||||
@ -13,6 +14,9 @@ export default function PostDisplay({
|
||||
loggedIn: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@ -96,7 +100,9 @@ export default function PostDisplay({
|
||||
<div>
|
||||
<div>
|
||||
<span>published: </span>
|
||||
<span>{post.publishedDate.toLocaleDateString()}</span>
|
||||
<span>
|
||||
{post.publishedDate.toLocaleDateString("en-UK")}
|
||||
</span>
|
||||
</div>
|
||||
{loggedIn && (
|
||||
<div
|
||||
@ -151,25 +157,55 @@ export default function PostDisplay({
|
||||
}}
|
||||
>
|
||||
{loggedIn ? (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "#a66",
|
||||
color: "#111",
|
||||
fontSize: "0.8rem",
|
||||
padding: "0.5rem",
|
||||
transition: "background-color 0.3s linear",
|
||||
userSelect: "none",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onMouseOver={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "#c88";
|
||||
}}
|
||||
onMouseOut={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "#a66";
|
||||
}}
|
||||
onClick={() => router.push(`/blog/write/${post.slug}`)}
|
||||
>
|
||||
edit
|
||||
<div style={{ display: "flex", gap: "1rem" }}>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "#6a6",
|
||||
color: "#111",
|
||||
fontSize: "0.8rem",
|
||||
padding: "0.5rem",
|
||||
transition: "background-color 0.3s linear",
|
||||
userSelect: "none",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onMouseOver={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "#8c8";
|
||||
}}
|
||||
onMouseOut={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "#6a6";
|
||||
}}
|
||||
onClick={() => router.push(`/blog/write/${post.slug}`)}
|
||||
>
|
||||
edit
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "#a66",
|
||||
color: "#111",
|
||||
fontSize: "0.8rem",
|
||||
padding: "0.5rem",
|
||||
transition: "background-color 0.3s linear",
|
||||
userSelect: "none",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onMouseOver={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "#c88";
|
||||
}}
|
||||
onMouseOut={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "#a66";
|
||||
}}
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
>
|
||||
delete
|
||||
</div>
|
||||
<DeleteModal
|
||||
open={confirmDelete}
|
||||
onCancel={() => setConfirmDelete(false)}
|
||||
onConfirm={async () => {
|
||||
await deleteArticle(post.slug);
|
||||
router.push("/blog");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div></div>
|
||||
@ -198,3 +234,96 @@ export default function PostDisplay({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteModal({
|
||||
open,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
id="deleteModal"
|
||||
style={{
|
||||
display: open
|
||||
? "flex"
|
||||
: "none" /* Will be changed to "flex" when opened */,
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.7)",
|
||||
zIndex: 1000,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.target == e.currentTarget) {
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "#222",
|
||||
padding: "1.5rem",
|
||||
borderRadius: "4px",
|
||||
maxWidth: "400px",
|
||||
width: "90%",
|
||||
boxShadow: "0 0 10px rgba(0, 0, 0, 0.5)",
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 style={{ margin: "0 0 1rem 0", color: "#eee" }}>
|
||||
Confirm Deletion
|
||||
</h3>
|
||||
<p style={{ marginBottom: "1.5rem", color: "#ccc" }}>
|
||||
Are you sure you want to delete this post? This action cannot be
|
||||
undone.
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: "0.75rem",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
style={{
|
||||
backgroundColor: "#a66",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
padding: "0.5rem 1rem",
|
||||
cursor: "pointer",
|
||||
borderRadius: "2px",
|
||||
}}
|
||||
onClick={() => {
|
||||
onConfirm();
|
||||
}}
|
||||
>
|
||||
Yes, delete it
|
||||
</button>
|
||||
<button
|
||||
style={{
|
||||
backgroundColor: "#555",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
padding: "0.5rem 1rem",
|
||||
cursor: "pointer",
|
||||
borderRadius: "2px",
|
||||
}}
|
||||
onClick={() => {
|
||||
onCancel();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getPost } from "../../action";
|
||||
import PostDisplay from "./PostDisplay";
|
||||
import { isLoggedIn } from "@/components/auth";
|
||||
import { auth } from "@/auth";
|
||||
|
||||
export default async function Post({
|
||||
params,
|
||||
@ -13,7 +13,7 @@ export default async function Post({
|
||||
if (!post) {
|
||||
notFound();
|
||||
}
|
||||
const loggedIn = await isLoggedIn();
|
||||
const loggedIn = (await auth())?.user != null;
|
||||
return (
|
||||
<>
|
||||
<PostDisplay post={post} loggedIn={loggedIn} />
|
||||
|
@ -6,6 +6,7 @@ import { KeyboardEvent } from "react";
|
||||
const md = new MarkdownIt({
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
breaks: true,
|
||||
highlight: (str, lang) => {
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
try {
|
||||
|
@ -1,16 +1,16 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Post } from "../../types";
|
||||
import Write from "../Write";
|
||||
import { isLoggedIn } from "@/components/auth";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { auth, signIn } from "@/auth";
|
||||
|
||||
export default async function WritePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string[] | undefined }>;
|
||||
}) {
|
||||
if (!(await isLoggedIn())) {
|
||||
redirect("/blog/login");
|
||||
if ((await auth())?.user == null) {
|
||||
await signIn();
|
||||
}
|
||||
|
||||
const slug = (await params).slug?.[0];
|
||||
|
@ -5,6 +5,7 @@ import MarkdownIt from "markdown-it";
|
||||
import hljs from "highlight.js";
|
||||
import * as cheerio from "cheerio";
|
||||
import slugify from "slugify";
|
||||
import { auth } from "@/auth";
|
||||
|
||||
export async function savePostServer(
|
||||
title: string,
|
||||
@ -13,11 +14,16 @@ export async function savePostServer(
|
||||
is_draft: boolean,
|
||||
existingSlug?: string
|
||||
) {
|
||||
if ((await auth())?.user == null) {
|
||||
throw new Error("Not authenticated");
|
||||
}
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const md = new MarkdownIt({
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
breaks: true,
|
||||
highlight: (str, lang) => {
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
try {
|
||||
@ -33,7 +39,22 @@ export async function savePostServer(
|
||||
const slug = slugify(title, { lower: true, strict: true });
|
||||
|
||||
if (existingSlug) {
|
||||
const post = await prisma.post.findUnique({
|
||||
where: { slug: existingSlug },
|
||||
include: { tags: true },
|
||||
});
|
||||
if (!post) {
|
||||
throw new Error("Post not found");
|
||||
}
|
||||
await prisma.post.delete({ where: { slug: existingSlug } });
|
||||
for (const tag of post.tags) {
|
||||
const postsWithTag = await prisma.post.count({
|
||||
where: { tags: { some: { id: tag.id } } },
|
||||
});
|
||||
if (postsWithTag == 0) {
|
||||
await prisma.tag.delete({ where: { id: tag.id } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.post.create({
|
||||
|
46
src/app/clipboard/ClipboardComponent.tsx
Normal file
46
src/app/clipboard/ClipboardComponent.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { updateClipboard } from "./action";
|
||||
|
||||
export default function ClipboardComponent({
|
||||
initialContent,
|
||||
}: {
|
||||
initialContent: string;
|
||||
}) {
|
||||
const [clipboardContent, setClipboardContent] = useState(initialContent);
|
||||
const [typingTimeout, setTypingTimeout] = useState<NodeJS.Timeout | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const contentChanged = (content: string) => {
|
||||
setClipboardContent(content);
|
||||
if (typingTimeout) {
|
||||
clearTimeout(typingTimeout);
|
||||
}
|
||||
const timeout = setTimeout(async () => {
|
||||
await updateClipboard(content);
|
||||
}, 500);
|
||||
setTypingTimeout(timeout);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<textarea
|
||||
style={{
|
||||
width: "100%",
|
||||
resize: "none",
|
||||
borderStyle: "none",
|
||||
backgroundColor: "#333",
|
||||
height: "20rem",
|
||||
maxHeight: "80vh",
|
||||
color: "#eee",
|
||||
}}
|
||||
onChange={(e) => contentChanged(e.target.value)}
|
||||
onBlur={async (e) => {
|
||||
await updateClipboard(e.target.value);
|
||||
}}
|
||||
value={clipboardContent}
|
||||
></textarea>
|
||||
</>
|
||||
);
|
||||
}
|
19
src/app/clipboard/action.ts
Normal file
19
src/app/clipboard/action.ts
Normal file
@ -0,0 +1,19 @@
|
||||
"use server";
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
export async function updateClipboard(content: string) {
|
||||
const prisma = new PrismaClient();
|
||||
await prisma.clipboard.upsert({
|
||||
where: { id: 1 },
|
||||
update: { content },
|
||||
create: { content },
|
||||
});
|
||||
}
|
||||
export async function getClipboard(): Promise<string> {
|
||||
const prisma = new PrismaClient();
|
||||
const clipboard = await prisma.clipboard.findUnique({
|
||||
where: { id: 1 },
|
||||
});
|
||||
return clipboard?.content || "";
|
||||
}
|
18
src/app/clipboard/page.tsx
Normal file
18
src/app/clipboard/page.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
"use server";
|
||||
|
||||
import { auth, signIn } from "@/auth";
|
||||
import { getClipboard } from "./action";
|
||||
import ClipboardComponent from "./ClipboardComponent";
|
||||
|
||||
export default async function ClipboardPage() {
|
||||
if ((await auth())?.user == null) {
|
||||
await signIn();
|
||||
}
|
||||
|
||||
const clipboard = await getClipboard();
|
||||
return (
|
||||
<>
|
||||
<ClipboardComponent initialContent={clipboard} />
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,4 +1,80 @@
|
||||
"use server";
|
||||
|
||||
import { createTransport } from "nodemailer";
|
||||
|
||||
export async function getReCaptchaSiteKey() {
|
||||
return process.env.RECAPTCHA_SITE_KEY;
|
||||
}
|
||||
|
||||
const RECAPTCHA_SERVER_KEY = process.env.RECAPTCHA_SERVER_KEY;
|
||||
|
||||
export type SubmitContactReturn = { error: string } | { success: true };
|
||||
|
||||
export default async function SubmitContact(
|
||||
prevState: unknown,
|
||||
data: FormData
|
||||
): Promise<SubmitContactReturn> {
|
||||
if (!RECAPTCHA_SERVER_KEY) {
|
||||
console.error(
|
||||
"RECAPTCHA_SERVER_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_SERVER_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 };
|
||||
}
|
||||
|
@ -1,9 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import SubmitContact from "@/components/contact";
|
||||
import { useActionState, useEffect, useState } from "react";
|
||||
import ReCAPTCHA from "react-google-recaptcha";
|
||||
import { getReCaptchaSiteKey } from "./action";
|
||||
import SubmitContact, { getReCaptchaSiteKey } from "./action";
|
||||
|
||||
export default function ContactPage() {
|
||||
const [recaptchaSiteKey, setRecaptchaSiteKey] = useState<string | undefined>(
|
||||
|
@ -3,17 +3,21 @@ import "./globals.css";
|
||||
import Title from "@/components/Title";
|
||||
import Navbar from "@/components/Navbar";
|
||||
import { bodyFont } from "@/components/fonts";
|
||||
import Link from "next/link";
|
||||
import { auth } from "@/auth";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "nrx.sh",
|
||||
description: "naresh's site",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const isLoggedIn = (await auth())?.user != null;
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
@ -27,7 +31,7 @@ export default function RootLayout({
|
||||
>
|
||||
<Title />
|
||||
|
||||
<Navbar />
|
||||
<Navbar isLoggedIn={isLoggedIn} />
|
||||
<div
|
||||
className={bodyFont.className}
|
||||
style={{
|
||||
@ -37,11 +41,29 @@ export default function RootLayout({
|
||||
maxWidth: "100vw",
|
||||
padding: "2rem",
|
||||
boxSizing: "border-box",
|
||||
fontSize: "1.1rem",
|
||||
fontSize: "0.9rem",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.7rem",
|
||||
marginTop: "2rem",
|
||||
textAlign: "center",
|
||||
color: "#888",
|
||||
maxWidth: "80vw",
|
||||
}}
|
||||
className={bodyFont.className}
|
||||
>
|
||||
i built this site from scratch using <b>next.js</b> - it is
|
||||
<Link
|
||||
style={{ color: "#88f" }}
|
||||
href={"https://git.nrx.sh/naresh/nrx.sh"}
|
||||
>
|
||||
completely open source
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -1,7 +1,6 @@
|
||||
"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() {
|
||||
@ -17,9 +16,6 @@ export default function Links() {
|
||||
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>
|
||||
@ -27,7 +23,7 @@ export default function Links() {
|
||||
<SocialIcon network="linkedin" bgColor="#111" /> LinkedIn
|
||||
</Link>
|
||||
<Link href="https://git.nrx.sh">
|
||||
<SiGitea size={40} /> Private Git Repo
|
||||
<SiGitea size={40} /> Self-Hosted Git Repos
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
|
@ -1,5 +1,3 @@
|
||||
/* eslint-disable react/no-unescaped-entities */
|
||||
|
||||
import { titleFont } from "@/components/fonts";
|
||||
import Link from "next/link";
|
||||
|
||||
@ -11,11 +9,11 @@ export default function NotFound() {
|
||||
</span>
|
||||
<br />
|
||||
<br />
|
||||
shit... something is fucked and now you're here
|
||||
shit... something is fucked and now you're here
|
||||
<br />
|
||||
i'll look into it, at some point
|
||||
i'll look into it, at some point
|
||||
<br />
|
||||
but for now, maybe you'd like to
|
||||
but for now, maybe you'd like to
|
||||
<br />
|
||||
<br />
|
||||
<Link
|
||||
|
@ -1,5 +1,3 @@
|
||||
/* eslint-disable react/no-unescaped-entities */
|
||||
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
@ -9,12 +7,14 @@ export default function App() {
|
||||
<>
|
||||
hi,
|
||||
<br />
|
||||
<br /> thanks for stopping by.
|
||||
<br /> how nice of you to stop by.
|
||||
<br />
|
||||
<br /> this is still a work in progress, so it's a little sparse.
|
||||
<br /> this cute little site is my own little playground. my own corner of
|
||||
the web for me to share and show off.
|
||||
<br />
|
||||
<br /> feel free to poke around and see what you find.
|
||||
<br />
|
||||
<br /> naresh.
|
||||
<br /> — naresh
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
6
src/auth.ts
Normal file
6
src/auth.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import NextAuth from "next-auth";
|
||||
import Authentik from "next-auth/providers/authentik";
|
||||
|
||||
export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
providers: [Authentik],
|
||||
});
|
@ -4,6 +4,7 @@ import { useState } from "react";
|
||||
import React from "react";
|
||||
import { navBarFont } from "./fonts";
|
||||
import {
|
||||
ADMIN_PAGES,
|
||||
PAGES,
|
||||
Pages,
|
||||
pathNameFromSelectedPage,
|
||||
@ -11,7 +12,7 @@ import {
|
||||
} from "./pages";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
|
||||
export default function Navbar() {
|
||||
export default function Navbar({ isLoggedIn }: { isLoggedIn: boolean }) {
|
||||
const [hoveredPage, setHoveredPage] = useState<Pages | null>(null);
|
||||
|
||||
const pathName = usePathname();
|
||||
@ -41,7 +42,10 @@ export default function Navbar() {
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{PAGES.map((page, index) => (
|
||||
{PAGES.filter((p) => {
|
||||
if (!isLoggedIn && ADMIN_PAGES.includes(p)) return false;
|
||||
return true;
|
||||
}).map((page, index) => (
|
||||
<React.Fragment key={page}>
|
||||
<div
|
||||
style={navbarItem(page)}
|
||||
|
@ -1,49 +0,0 @@
|
||||
"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(): Promise<boolean> {
|
||||
const cookieStore = (await cookies()).get("session")?.value;
|
||||
const session = await decrypt(cookieStore);
|
||||
if (session != null && session.admin) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function setSession(): Promise<void> {
|
||||
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: "/",
|
||||
});
|
||||
}
|
@ -1,76 +0,0 @@
|
||||
"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 };
|
||||
}
|
@ -1,5 +1,6 @@
|
||||
export type Pages = "home" | "about" | "links" | "contact" | "blog";
|
||||
export const PAGES: Pages[] = ["home", "about", "blog", "links", "contact"];
|
||||
export type Pages = "home" | "about" | "links" | "contact" | "blog" | "clipboard";
|
||||
export const PAGES: Pages[] = ["home", "about", "blog", "links", "contact", "clipboard"];
|
||||
export const ADMIN_PAGES: Pages[] = ["clipboard"]
|
||||
|
||||
export function selectedPageFromPathName(pathName: string): Pages {
|
||||
if (pathName === "/") {
|
||||
|
Reference in New Issue
Block a user