Skip to content

Commit c3e44ca

Browse files
author
Arjun
committed
first
0 parents  commit c3e44ca

36 files changed

+2354
-0
lines changed

admin/form.tsx

+158
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
'use client';
2+
3+
import { useFormStatus } from 'react-dom';
4+
import { useState, useEffect } from 'react';
5+
import { deleteGuestbookEntries } from 'app/db/actions';
6+
7+
export default function Form({ entries }) {
8+
const [selectedInputs, setSelectedInputs] = useState<string[]>([]);
9+
const [startShiftClickIndex, setStartShiftClickIndex] = useState<number>(0);
10+
const [isShiftKeyPressed, setIsShiftKeyPressed] = useState(false);
11+
const [isCommandKeyPressed, setIsCommandKeyPressed] = useState(false);
12+
13+
useEffect(() => {
14+
const keyDownHandler = ({ key }) => {
15+
if (key === 'Shift') {
16+
setIsShiftKeyPressed(true);
17+
}
18+
if (key === 'Meta' || key === 'Control') {
19+
setIsCommandKeyPressed(true);
20+
}
21+
};
22+
const keyUpHandler = ({ key }) => {
23+
if (key === 'Shift') {
24+
setIsShiftKeyPressed(false);
25+
}
26+
if (key === 'Meta' || key === 'Control') {
27+
setIsCommandKeyPressed(false);
28+
}
29+
};
30+
31+
window.addEventListener('keydown', keyDownHandler);
32+
window.addEventListener('keyup', keyUpHandler);
33+
34+
return () => {
35+
window.removeEventListener('keydown', keyDownHandler);
36+
window.removeEventListener('keyup', keyUpHandler);
37+
};
38+
}, []);
39+
40+
const handleNormalClick = (checked: boolean, id: string, index: number) => {
41+
setSelectedInputs((prevInputs) =>
42+
checked
43+
? [...prevInputs, id]
44+
: prevInputs.filter((inputId) => inputId !== id)
45+
);
46+
setStartShiftClickIndex(index);
47+
};
48+
49+
const handleCommandClick = (id: string) => {
50+
setSelectedInputs((prevInputs) =>
51+
prevInputs.includes(id)
52+
? prevInputs.filter((inputId) => inputId !== id)
53+
: [...prevInputs, id]
54+
);
55+
};
56+
57+
const handleShiftClick = (index: number, checked: boolean) => {
58+
const startIndex = Math.min(startShiftClickIndex!, index);
59+
const endIndex = Math.max(startShiftClickIndex!, index);
60+
61+
setSelectedInputs((prevInputs) => {
62+
const newSelection = entries
63+
.slice(startIndex, endIndex + 1)
64+
.map((item) => item.id);
65+
66+
if (checked) {
67+
const combinedSelection = Array.from(
68+
new Set([...prevInputs, ...newSelection])
69+
);
70+
return combinedSelection;
71+
} else {
72+
return prevInputs.filter((inputId) => !newSelection.includes(inputId));
73+
}
74+
});
75+
};
76+
77+
const handleCheck = (checked: boolean, id: string, index: number) => {
78+
if (isCommandKeyPressed) {
79+
handleCommandClick(id);
80+
} else if (isShiftKeyPressed && startShiftClickIndex !== null) {
81+
handleShiftClick(index, checked);
82+
} else {
83+
handleNormalClick(checked, id, index);
84+
}
85+
};
86+
87+
const handleKeyDown = (
88+
event: React.KeyboardEvent<HTMLInputElement>,
89+
id: string,
90+
index: number
91+
) => {
92+
if (event.key === 'Enter') {
93+
// Check if the checkbox was already selected
94+
const isChecked = selectedInputs.includes(id);
95+
96+
// Toggle the checkbox
97+
handleCheck(!isChecked, id, index);
98+
}
99+
};
100+
101+
return (
102+
<form
103+
onSubmit={async (e) => {
104+
e.preventDefault();
105+
await deleteGuestbookEntries(selectedInputs);
106+
}}
107+
>
108+
<DeleteButton isActive={selectedInputs.length !== 0} />
109+
{entries.map((entry, index) => (
110+
<GuestbookEntry key={entry.id} entry={entry}>
111+
<input
112+
name={entry.id}
113+
type="checkbox"
114+
className="mr-2 w-4 h-4"
115+
onChange={(e) => handleCheck(e.target.checked, entry.id, index)}
116+
onKeyDown={(e) => handleKeyDown(e, entry.id, index)}
117+
checked={selectedInputs.includes(entry.id)}
118+
/>
119+
</GuestbookEntry>
120+
))}
121+
</form>
122+
);
123+
}
124+
125+
function GuestbookEntry({ entry, children }) {
126+
return (
127+
<div className="flex flex-col space-y-1 mb-4">
128+
<div className="w-full text-sm break-words items-center flex">
129+
{children}
130+
<span className="text-neutral-600 dark:text-neutral-400 mr-1 border-neutral-100">
131+
{entry.created_by}:
132+
</span>
133+
{entry.body}
134+
</div>
135+
</div>
136+
);
137+
}
138+
139+
const cx = (...classes) => classes.filter(Boolean).join(' ');
140+
141+
function DeleteButton({ isActive }) {
142+
const { pending } = useFormStatus();
143+
144+
return (
145+
<button
146+
className={cx(
147+
'px-3 py-2 border border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800 rounded p-1 text-sm inline-flex items-center leading-4 text-neutral-900 dark:text-neutral-100 mb-8 transition-all',
148+
{
149+
'bg-red-300/50 dark:bg-red-700/50': isActive,
150+
}
151+
)}
152+
disabled={pending}
153+
type="submit"
154+
>
155+
Delete Entries
156+
</button>
157+
);
158+
}

admin/page.tsx

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { auth } from 'app/auth';
2+
import { getGuestbookEntries } from 'app/db/queries';
3+
import { redirect } from 'next/navigation';
4+
import Form from './form';
5+
6+
export const metadata = {
7+
title: 'Admin',
8+
};
9+
10+
export default async function GuestbookPage() {
11+
let session = await auth();
12+
if (session?.user?.email !== '[email protected]') {
13+
redirect('/');
14+
}
15+
16+
let entries = await getGuestbookEntries();
17+
18+
return (
19+
<section>
20+
<h1 className="font-medium text-2xl mb-8 tracking-tighter">admin</h1>
21+
<Form entries={entries} />
22+
</section>
23+
);
24+
}

api/auth/[...nextauth]/route.ts

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { GET, POST } from 'app/auth';
2+
export const runtime = 'edge';

auth.ts

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import NextAuth from 'next-auth';
2+
import GitHub from 'next-auth/providers/github';
3+
4+
export const {
5+
handlers: { GET, POST },
6+
auth,
7+
} = NextAuth({
8+
providers: [
9+
GitHub({
10+
clientId: process.env.OAUTH_CLIENT_KEY as string,
11+
clientSecret: process.env.OAUTH_CLIENT_SECRET as string,
12+
}),
13+
],
14+
pages: {
15+
signIn: '/sign-in',
16+
},
17+
});

avatar.jpg

908 KB
Loading

blog/[slug]/page.tsx

+142
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import type { Metadata } from 'next';
2+
import { Suspense, cache } from 'react';
3+
import { notFound } from 'next/navigation';
4+
import { CustomMDX } from 'app/components/mdx';
5+
import { getViewsCount } from 'app/db/queries';
6+
import { getBlogPosts } from 'app/db/blog';
7+
import ViewCounter from '../view-counter';
8+
import { increment } from 'app/db/actions';
9+
import { unstable_noStore as noStore } from 'next/cache';
10+
11+
export async function generateMetadata({
12+
params,
13+
}): Promise<Metadata | undefined> {
14+
let post = getBlogPosts().find((post) => post.slug === params.slug);
15+
if (!post) {
16+
return;
17+
}
18+
19+
let {
20+
title,
21+
publishedAt: publishedTime,
22+
summary: description,
23+
image,
24+
} = post.metadata;
25+
let ogImage = image
26+
? `https://leerob.io${image}`
27+
: `https://leerob.io/og?title=${title}`;
28+
29+
return {
30+
title,
31+
description,
32+
openGraph: {
33+
title,
34+
description,
35+
type: 'article',
36+
publishedTime,
37+
url: `https://leerob.io/blog/${post.slug}`,
38+
images: [
39+
{
40+
url: ogImage,
41+
},
42+
],
43+
},
44+
twitter: {
45+
card: 'summary_large_image',
46+
title,
47+
description,
48+
images: [ogImage],
49+
},
50+
};
51+
}
52+
53+
function formatDate(date: string) {
54+
noStore();
55+
let currentDate = new Date().getTime();
56+
if (!date.includes('T')) {
57+
date = `${date}T00:00:00`;
58+
}
59+
let targetDate = new Date(date).getTime();
60+
let timeDifference = Math.abs(currentDate - targetDate);
61+
let daysAgo = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
62+
63+
let fullDate = new Date(date).toLocaleString('en-us', {
64+
month: 'long',
65+
day: 'numeric',
66+
year: 'numeric',
67+
});
68+
69+
if (daysAgo < 1) {
70+
return 'Today';
71+
} else if (daysAgo < 7) {
72+
return `${fullDate} (${daysAgo}d ago)`;
73+
} else if (daysAgo < 30) {
74+
const weeksAgo = Math.floor(daysAgo / 7)
75+
return `${fullDate} (${weeksAgo}w ago)`;
76+
} else if (daysAgo < 365) {
77+
const monthsAgo = Math.floor(daysAgo / 30)
78+
return `${fullDate} (${monthsAgo}mo ago)`;
79+
} else {
80+
const yearsAgo = Math.floor(daysAgo / 365)
81+
return `${fullDate} (${yearsAgo}y ago)`;
82+
}
83+
}
84+
85+
export default function Blog({ params }) {
86+
let post = getBlogPosts().find((post) => post.slug === params.slug);
87+
88+
if (!post) {
89+
notFound();
90+
}
91+
92+
return (
93+
<section>
94+
<script
95+
type="application/ld+json"
96+
suppressHydrationWarning
97+
dangerouslySetInnerHTML={{
98+
__html: JSON.stringify({
99+
'@context': 'https://schema.org',
100+
'@type': 'BlogPosting',
101+
headline: post.metadata.title,
102+
datePublished: post.metadata.publishedAt,
103+
dateModified: post.metadata.publishedAt,
104+
description: post.metadata.summary,
105+
image: post.metadata.image
106+
? `https://leerob.io${post.metadata.image}`
107+
: `https://leerob.io/og?title=${post.metadata.title}`,
108+
url: `https://leerob.io/blog/${post.slug}`,
109+
author: {
110+
'@type': 'Person',
111+
name: 'Lee Robinson',
112+
},
113+
}),
114+
}}
115+
/>
116+
<h1 className="title font-medium text-2xl tracking-tighter max-w-[650px]">
117+
{post.metadata.title}
118+
</h1>
119+
<div className="flex justify-between items-center mt-2 mb-8 text-sm max-w-[650px]">
120+
<Suspense fallback={<p className="h-5" />}>
121+
<p className="text-sm text-neutral-600 dark:text-neutral-400">
122+
{formatDate(post.metadata.publishedAt)}
123+
</p>
124+
</Suspense>
125+
<Suspense fallback={<p className="h-5" />}>
126+
<Views slug={post.slug} />
127+
</Suspense>
128+
</div>
129+
<article className="prose prose-quoteless prose-neutral dark:prose-invert">
130+
<CustomMDX source={post.content} />
131+
</article>
132+
</section>
133+
);
134+
}
135+
136+
let incrementViews = cache(increment);
137+
138+
async function Views({ slug }: { slug: string }) {
139+
let views = await getViewsCount();
140+
incrementViews(slug);
141+
return <ViewCounter allViews={views} slug={slug} />;
142+
}

blog/[slug]/sandpack.tsx

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
'use client';
2+
3+
import { getSandpackCssText } from '@codesandbox/sandpack-react';
4+
import { useServerInsertedHTML } from 'next/navigation';
5+
6+
export function SandpackCSS() {
7+
useServerInsertedHTML(() => {
8+
return (
9+
<style
10+
dangerouslySetInnerHTML={{ __html: getSandpackCssText() }}
11+
id="sandpack"
12+
/>
13+
);
14+
});
15+
return null;
16+
}

0 commit comments

Comments
 (0)