add functionality and create a very minimum viable product

This commit is contained in:
Aidan 2025-01-24 17:24:45 -05:00
parent cb02089224
commit c47438c033
27 changed files with 1646 additions and 473 deletions

79
app/admin/layout.tsx Normal file
View File

@ -0,0 +1,79 @@
'use client';
export const dynamic = 'force-dynamic';
import { useState, useEffect } from 'react';
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const [loading, setLoading] = useState(true);
const [authorized, setAuthorized] = useState(false);
useEffect(() => {
if (window.location.pathname.startsWith('/admin/login')) {
console.log("[i] Detected login page, skipping validation");
setAuthorized(true);
setLoading(false);
return;
}
const cookies = document.cookie.split(';').reduce((acc, cookie) => {
const [name, value] = cookie.split('=').map((c) => c.trim());
acc[name] = value;
return acc;
}, {} as Record<string, string>);
const validate = async () => {
const key = cookies.key;
if (!key) {
console.log("[!] No key found, clearing cookies and redirecting to login");
document.cookie.split(';').forEach((cookie) => {
const [name] = cookie.split('=');
document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/`;
});
window.location.href = '/admin/login';
return;
}
const response = await fetch('http://localhost:3001/api/admin/validate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ key: cookies.key, username: cookies.username }),
});
if (!response.ok) {
console.log('[!] Failed to check key, skipping validation and clearing cookie');
document.cookie = 'key=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';
window.location.href = '/admin/login';
} else {
const data = await response.json()
if (data.success) {
console.log("[✓] Key is valid");
setAuthorized(true);
} else {
console.log("[✖] Key is invalid, clearing cookie and redirecting to login");
document.cookie = 'key=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';
window.location.href = '/admin/login';
}
}
setLoading(false);
};
if (typeof window !== 'undefined') {
validate();
}
}, []);
if (loading) {
return (
<div className="flex items-center justify-center h-[80vh]">
<div className="animate-spin rounded-full h-16 w-16 border-4 border-t-slate-800 border-white"></div>
</div>
);
}
if (!authorized) {
return null;
}
return <div className="relative flex min-h-screen flex-col">{children}</div>;
}

20
app/admin/logout/page.tsx Normal file
View File

@ -0,0 +1,20 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
export default function Logout() {
const router = useRouter();
useEffect(() => {
document.cookie.split(";").forEach((cookie) => {
document.cookie = cookie
.replace(/^ +/, "")
.replace(/=.*/, "=;expires=" + new Date().toUTCString() + ";path=/");
});
router.push('/admin/login');
}, [router]);
return null;
}

View File

@ -1,44 +1,121 @@
'use client';
import { useEffect, useState } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import strings from "@/strings.json" import strings from "@/strings.json"
import { PlusCircle, UserPlus } from "lucide-react" import { PlusCircle, UserPlus, CircleAlert } from "lucide-react"
const posts = [ export default function Home() {
{ const [totalPosts, setTotalPosts] = useState(0);
id: 1, const [postCardError, setPostCardError] = useState(false);
title: "Sample Post 1", const [postCardLoading, setPostCardLoading] = useState(true);
description: "Description", const [totalUsers, setTotalUsers] = useState(0);
date: "2025-01-14", const [userCtCardError, setUserCtCardError] = useState(false);
category: "Example Category 1", const [userCtCardLoading, setUserCtCardLoading] = useState(true);
slug: "sample-post-1",
}, useEffect(() => {
{ console.log("[i] Calculating post count...");
id: 2, (async () => {
title: "Sample Post 2", try {
description: "Description", const username = document.cookie.split('; ').find(row => row.startsWith('username='))?.split('=')[1] || '';
date: "2025-01-14", const key = document.cookie.split('; ').find(row => row.startsWith('key='))?.split('=')[1] || '';
category: "Example Category 1",
slug: "sample-post-2", const res = await fetch(`http://localhost:3001/api/admin/posts/totalPosts`, {
}, method: 'POST',
{ headers: {
id: 3, 'Content-Type': 'application/json',
title: "Sample Post 3", },
description: "Description", body: JSON.stringify({
date: "2025-01-14", username,
category: "Example Category 2", key
slug: "sample-post-3", }),
}, cache: 'no-store',
{ });
id: 4,
title: "Sample Post 4", if (!res.ok) {
description: "Description", alert('Failed to fetch total post count');
date: "2025-01-14", setPostCardError(true);
category: "Example Category 2", throw new Error(`Failed to fetch total post count: ${res.status}`);
slug: "sample-post-4", }
},
] const data = await res.json();
if (data.success === false) {
if (data.message) {
alert(data.message);
setPostCardError(true);
setPostCardLoading(false);
throw new Error(data.message);
} else {
alert('Unknown error occurred');
setPostCardError(true);
setPostCardLoading(false);
throw new Error('Unknown error occurred');
}
} else if (data.count) {
console.log("[✓] Total posts:", data.count);
setTotalPosts(data.count);
setPostCardLoading(false);
}
} catch (error) {
alert('Error fetching total post count');
setPostCardError(true);
setPostCardLoading(false);
console.error('[!] Failed to fetch total post count:', error);
}
})();
console.log("[i] Calculating user count...");
(async () => {
try {
const username = document.cookie.split('; ').find(row => row.startsWith('username='))?.split('=')[1] || '';
const key = document.cookie.split('; ').find(row => row.startsWith('key='))?.split('=')[1] || '';
const res = await fetch(`http://localhost:3001/api/admin/users/totalUsers`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username,
key
}),
cache: 'no-store',
});
if (!res.ok) {
alert('Failed to fetch total user count');
setUserCtCardError(true);
throw new Error(`Failed to fetch total user count: ${res.status}`);
}
const data = await res.json();
if (data.success === false) {
if (data.message) {
alert(data.message);
setUserCtCardError(true);
setUserCtCardLoading(false);
throw new Error(data.message);
} else {
alert('Unknown error occurred');
setUserCtCardError(true);
setUserCtCardLoading(false);
throw new Error('Unknown error occurred');
}
} else if (data.count) {
console.log("[✓] Total users:", data.count);
setTotalUsers(data.count);
setUserCtCardLoading(false);
}
} catch (error) {
alert('Error fetching total user count');
setUserCtCardError(true);
setUserCtCardLoading(false);
console.error('[!] Failed to fetch total user count:', error);
}
})();
}, []);
export default function Admin() {
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<h1 className="text-4xl font-bold text-primary">{strings.adminHeader}</h1> <h1 className="text-4xl font-bold text-primary">{strings.adminHeader}</h1>
@ -46,20 +123,50 @@ export default function Admin() {
<Card className="flex flex-col justify-between"> <Card className="flex flex-col justify-between">
<CardHeader> <CardHeader>
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">
<CardTitle className="text-xl text-primary">{strings.totalPostsCardTitle}</CardTitle> <CardTitle className="text-xl text-primary">{strings.totalUsersCardTitle}</CardTitle>
<span className="text-4xl font-bold text-primary ml-2"> <span className="text-4xl font-bold text-primary ml-2">
{posts.length} {userCtCardLoading ? (
<div className="animate-spin rounded-full h-16 w-16 border-4 border-t-slate-800 border-white"></div>
) : userCtCardError ? (
<div className="flex items-center text-red-500">
<CircleAlert />
<p className="text-base ml-1.5">Error</p>
</div>
) : (
totalUsers
)}
</span> </span>
</div> </div>
</CardHeader> </CardHeader>
</Card> </Card>
<Card className="w-full max-w-sm"> <Card className="flex flex-col justify-between">
<CardHeader>
<div className="flex justify-between items-start">
<CardTitle className="text-xl text-primary">{strings.totalPostsCardTitle}</CardTitle>
<span className="text-4xl font-bold text-primary ml-2">
{postCardLoading ? (
<div className="animate-spin rounded-full h-16 w-16 border-4 border-t-slate-800 border-white"></div>
) : postCardError ? (
<div className="flex items-center text-red-500">
<CircleAlert />
<p className="text-base ml-1.5">Error</p>
</div>
) : (
totalPosts
)}
</span>
</div>
</CardHeader>
</Card>
</div>
<div className="grid gap-6 sm:grid-cols-3 lg:grid-cols-4">
<Card className="flex flex-col justify-between">
<CardHeader> <CardHeader>
<CardTitle>Quick Actions</CardTitle> <CardTitle>Quick Actions</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="grid gap-4"> <CardContent className="grid gap-4">
<Button className="w-full justify-start" variant="outline" asChild> <Button className="w-full justify-start" variant="outline" asChild>
<a href="/admin/post"> <a href="/admin/posts/new">
<PlusCircle className="mr-2 h-4 w-4" /> <PlusCircle className="mr-2 h-4 w-4" />
New Post New Post
</a> </a>

106
app/admin/posts/page.tsx Normal file
View File

@ -0,0 +1,106 @@
'use client';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"
import strings from "@/strings.json"
import { PlusCircle, CircleAlert } from "lucide-react"
export default function Posts() {
const [totalPosts, setTotalPosts] = useState(0);
const [postCardError, setPostCardError] = useState(false);
const [postCardLoading, setPostCardLoading] = useState(true);
useEffect(() => {
console.log("[i] Calculating post count...");
(async () => {
try {
const username = document.cookie.split('; ').find(row => row.startsWith('username='))?.split('=')[1] || '';
const key = document.cookie.split('; ').find(row => row.startsWith('key='))?.split('=')[1] || '';
const res = await fetch(`http://localhost:3001/api/admin/posts/totalPosts`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username,
key
}),
cache: 'no-store',
});
if (!res.ok) {
alert('Failed to fetch total post count');
setPostCardError(true);
throw new Error(`Failed to fetch total post count: ${res.status}`);
}
const data = await res.json();
if (data.success === false) {
if (data.message) {
alert(data.message);
setPostCardError(true);
setPostCardLoading(false);
throw new Error(data.message);
} else {
alert('Unknown error occurred');
setPostCardError(true);
setPostCardLoading(false);
throw new Error('Unknown error occurred');
}
} else if (data.count) {
console.log("[✓] Total posts:", data.count);
setTotalPosts(data.count);
setPostCardLoading(false);
}
} catch (error) {
alert('Error fetching total post count');
setPostCardError(true);
setPostCardLoading(false);
console.error('[!] Failed to fetch total post count:', error);
}
})();
}, []);
return (
<div className="space-y-8">
<h1 className="text-4xl font-bold text-primary">Posts</h1>
<div className="grid gap-6 sm:grid-cols-3 lg:grid-cols-4">
<Card className="flex flex-col justify-between">
<CardHeader>
<CardTitle>Quick Actions</CardTitle>
</CardHeader>
<CardContent className="grid gap-4">
<Button className="w-full justify-start" variant="outline" asChild>
<a href="/admin/post">
<PlusCircle className="mr-2 h-4 w-4" />
New Post
</a>
</Button>
</CardContent>
</Card>
<Card className="flex flex-col justify-between">
<CardHeader>
<div className="flex justify-between items-start">
<CardTitle className="text-xl text-primary">{strings.totalPostsCardTitle}</CardTitle>
<span className="text-4xl font-bold text-primary ml-2">
{postCardLoading ? (
<div className="animate-spin rounded-full h-16 w-16 border-4 border-t-slate-800 border-white"></div>
) : postCardError ? (
<div className="flex items-center text-red-500">
<CircleAlert />
<p className="text-base ml-1.5">Error</p>
</div>
) : (
totalPosts
)}
</span>
</div>
</CardHeader>
</Card>
</div>
</div>
)
}

View File

@ -1,32 +1,100 @@
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" 'use client';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"
import strings from "@/strings.json" import strings from "@/strings.json"
import { UserPlus, CircleAlert } from "lucide-react"
export default function Users() { export default function Users() {
const [totalUsers, setTotalUsers] = useState(0);
const [userCtCardError, setUserCtCardError] = useState(false);
const [userCtCardLoading, setUserCtCardLoading] = useState(true);
useEffect(() => {
console.log("[i] Calculating user count...");
(async () => {
try {
const username = document.cookie.split('; ').find(row => row.startsWith('username='))?.split('=')[1] || '';
const key = document.cookie.split('; ').find(row => row.startsWith('key='))?.split('=')[1] || '';
const res = await fetch(`http://localhost:3001/api/admin/users/totalUsers`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username,
key
}),
cache: 'no-store',
});
if (!res.ok) {
alert('Failed to fetch total user count');
setUserCtCardError(true);
throw new Error(`Failed to fetch total user count: ${res.status}`);
}
const data = await res.json();
if (data.success === false) {
if (data.message) {
alert(data.message);
setUserCtCardError(true);
setUserCtCardLoading(false);
throw new Error(data.message);
} else {
alert('Unknown error occurred');
setUserCtCardError(true);
setUserCtCardLoading(false);
throw new Error('Unknown error occurred');
}
} else if (data.count) {
console.log("[✓] Total users:", data.count);
setTotalUsers(data.count);
setUserCtCardLoading(false);
}
} catch (error) {
alert('Error fetching total user count');
setUserCtCardError(true);
setUserCtCardLoading(false);
console.error('[!] Failed to fetch total user count:', error);
}
})();
}, []);
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<h1 className="text-4xl font-bold text-primary">{strings.usersHeader}</h1> <h1 className="text-4xl font-bold text-primary">{strings.usersHeader}</h1>
<div className="grid gap-6 sm:grid-cols-3 lg:grid-cols-4"> <div className="grid gap-6 sm:grid-cols-3 lg:grid-cols-4">
<Card className="flex flex-col justify-between"> <Card className="flex flex-col justify-between">
<CardHeader> <CardHeader>
<div className="flex justify-between items-start"> <CardTitle>Quick Actions</CardTitle>
<CardTitle className="text-xl text-primary">{strings.totalUsersCardTitle}</CardTitle>
<span className="text-4xl font-bold text-primary ml-2">
{/* TODO: Implement user logic and counter */}
57
</span>
</div>
</CardHeader> </CardHeader>
<CardContent className="grid gap-4">
<Button className="w-full justify-start" variant="outline" asChild>
<a href="/admin/users/new">
<UserPlus className="mr-2 h-4 w-4" />
New User
</a>
</Button>
</CardContent>
</Card> </Card>
<Card className="flex flex-col justify-between"> <Card className="flex flex-col justify-between">
<CardHeader> <CardHeader>
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">
<CardDescription> <CardTitle className="text-xl text-primary">{strings.totalUsersCardTitle}</CardTitle>
<CardTitle className="text-xl text-primary">{strings.totalUsersLoggedInMonthCardTitle}</CardTitle>
<p className="text-sm italic text-muted-foreground mt-1">{strings.totalUsersLoggedInMonthCardDescription}</p>
</CardDescription>
<span className="text-4xl font-bold text-primary ml-2"> <span className="text-4xl font-bold text-primary ml-2">
{/* TODO: Implement users logged in (by month) logic + counter */} {userCtCardLoading ? (
24 <div className="animate-spin rounded-full h-16 w-16 border-4 border-t-slate-800 border-white"></div>
) : userCtCardError ? (
<div className="flex items-center text-red-500">
<CircleAlert />
<p className="text-base ml-1.5">Error</p>
</div>
) : (
totalUsers
)}
</span> </span>
</div> </div>
</CardHeader> </CardHeader>

View File

@ -1,82 +1,117 @@
"use client"
import { useEffect, useState } from "react"
import { formatDistanceToNow, format } from "date-fns"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import Link from "next/link" import Link from "next/link"
import strings from "@/strings.json" import strings from "@/strings.json"
const posts = [ interface Category {
{ id: number
id: 1, name: string
title: "Sample Post 1", description: string
description: "Description", slug: string
date: "2025-01-14", }
category: "Example Category 1",
slug: "sample-post-1",
},
{
id: 2,
title: "Sample Post 2",
description: "Description",
date: "2025-01-14",
category: "Example Category 1",
slug: "sample-post-2",
},
{
id: 3,
title: "Sample Post 3",
description: "Description",
date: "2025-01-14",
category: "Example Category 2",
slug: "sample-post-3",
},
{
id: 4,
title: "Sample Post 4",
description: "Description",
date: "2025-01-14",
category: "Example Category 2",
slug: "sample-post-4",
},
]
export default function Categories() { interface Post {
const categories = posts.reduce((acc, post) => { category: string
acc[post.category] = (acc[post.category] || 0) + 1; date: number
return acc; }
}, {} as Record<string, number>);
export default function Home() {
const [posts, setPosts] = useState<Post[]>([])
const [categories, setCategories] = useState<Category[]>([])
const [error, setError] = useState("")
const [loading, setLoading] = useState(true)
useEffect(() => {
Promise.all([
fetch("http://localhost:3001/api/categories/fetchList").then((res) => res.json()),
fetch("http://localhost:3001/api/posts/fetchList").then((res) => res.json()),
])
.then(([categoriesData, postsData]) => {
setCategories(categoriesData.categories)
setPosts(postsData.posts)
setLoading(false)
})
.catch((error) => {
console.error("[!] Error fetching data:", error)
setError("Failed to fetch data")
setLoading(false)
})
}, [])
const formatDate = (timestamp: number) => {
const date = new Date(timestamp * 1000)
const now = new Date()
if (date.getFullYear() !== now.getFullYear()) {
return format(date, "d MMMM, yyyy")
} else {
return formatDistanceToNow(date, { addSuffix: true })
}
}
const getCategoryPostCount = (categoryName: string) => {
return posts.filter((post) => post.category === categoryName).length
}
const getLastUpdatedDate = (categoryName: string) => {
const categoryPosts = posts.filter((post) => post.category === categoryName)
if (categoryPosts.length === 0) return null
return Math.max(...categoryPosts.map((post) => post.date))
}
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<h1 className="text-4xl font-bold text-primary">{strings.categoriesHeader}</h1> <h1 className="text-4xl font-bold text-primary">{strings.categoriesHeader}</h1>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3"> {loading ? (
{Object.entries(categories).map(([category, count]) => ( <div className="flex items-center justify-center h-[80vh]">
<Card key={category} className="flex flex-col justify-between border-border/40 hover:border-border/60 transition-colors"> <div className="animate-spin rounded-full h-16 w-16 border-4 border-t-slate-800 border-white"></div>
<CardHeader> </div>
<div className="flex justify-between items-start"> ) : error ? (
<CardTitle className="text-xl text-primary">{category}</CardTitle> <div className="text-red-500">{error}</div>
<Badge variant="secondary" className="ml-2"> ) : (
{count} {count === 1 ? strings.categoriesPostUnitSingle : strings.categoriesPostUnitPlural } <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
</Badge> {categories.map((category) => {
</div> const postCount = getCategoryPostCount(category.name)
<CardDescription className="mt-2"> const lastUpdated = getLastUpdatedDate(category.name)
{strings.categoriesCardDescriptionPre} {category} return (
</CardDescription> <Card
</CardHeader> key={category.id}
<CardContent> className="flex flex-col justify-between border-border/40 hover:border-border/60 transition-colors"
<p className="text-sm text-muted-foreground">
{strings.categoriesLastUpdatedLabel}: {posts.find(post => post.category === category)?.date}
</p>
</CardContent>
<CardFooter className="flex justify-end">
<Link
href={`/categories/${category.toLowerCase()}`}
className="text-sm font-medium text-primary hover:underline"
> >
{strings.categoriesViewPostsFromLinkText} <CardHeader>
</Link> <div className="flex justify-between items-start">
</CardFooter> <CardTitle className="text-xl text-primary">{category.name}</CardTitle>
</Card> <Badge variant="secondary" className="ml-2">
))} {postCount}{" "}
</div> {postCount === 1 ? strings.categoriesPostUnitSingle : strings.categoriesPostUnitPlural}
</Badge>
</div>
<CardDescription className="mt-2">{category.description}</CardDescription>
</CardHeader>
<CardContent>
{lastUpdated && (
<p className="text-sm text-muted-foreground">
{strings.categoriesLastUpdatedLabel} {formatDate(lastUpdated)}
</p>
)}
</CardContent>
<CardFooter className="flex justify-end">
<Link
href={`/category/${category.slug}`}
className="text-sm font-medium text-primary hover:underline"
>
{strings.categoriesViewPostsFromLinkText}
</Link>
</CardFooter>
</Card>
)
})}
</div>
)}
</div> </div>
) )
} }

View File

@ -0,0 +1,120 @@
'use client';
import { useEffect, useState } from 'react';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import Link from "next/link"
import strings from "@/strings.json"
import { formatDistanceToNow, format } from 'date-fns'
export default function CategorySlug() {
const [posts, setPosts] = useState([]);
const [error, setError] = useState('');
const [category, setCategory] = useState('Category View'); // TODO: needs a better title
const [loading, setLoading] = useState(true);
useEffect(() => {
console.log("[i] Fetching post list...");
(async () => {
try {
const catReq = await fetch(`http://localhost:3001/api/categories/fetchList`, {
method: 'GET',
});
const postReq = await fetch(`http://localhost:3001/api/posts/fetchList`, {
method: 'GET',
});
if (!catReq.ok) {
throw new Error(`Failed to fetch category list: ${catReq.status}`);
}
if (!postReq.ok) {
throw new Error(`Failed to fetch post list: ${postReq.status}`);
}
const catData = await catReq.json();
const postData = await postReq.json();
if (!catData) {
setError('Failed to fetch category list');
} else {
console.log("[✓] Fetched categories");
const slug = window.location.pathname.split('/').slice(-1)[0];
const category = catData.categories.find((cat: { slug: string }) => cat.slug === slug);
if (category) {
console.log(`[✓] Found category: ${category.name}`);
setCategory(category.name);
if (postData.success === false) {
if (postData.message) {
throw new Error(postData.message);
} else {
throw new Error('Unknown error occurred');
}
} else {
const sortedPosts = postData.posts.sort((a, b) => b.date - a.date);
setPosts(sortedPosts);
}
} else {
setError('Could not find requested category');
throw new Error(`Category with slug "${slug}" not found`);
}
}
} catch (error) {
console.error('[!] Error fetching post list:', error);
setError('Failed to fetch post list. Please try again later.');
} finally {
setLoading(false);
}
})();
}, []);
const formatDate = (timestamp) => {
const date = new Date(timestamp * 1000);
const now = new Date();
if (date.getFullYear() !== now.getFullYear()) {
return format(date, 'MM/DD/YYYY');
} else {
return formatDistanceToNow(date, { addSuffix: true });
}
};
return (
<div className="space-y-8">
{!loading && <h1 className="text-4xl font-bold text-primary">{category}</h1>}
{loading ? (
<div className="flex items-center justify-center h-[80vh]">
<div className="animate-spin rounded-full h-16 w-16 border-4 border-t-slate-800 border-white"></div>
</div>
) : error ? (
<div className="text-red-500">{error}</div>
) : (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{posts.map((post) => (
<Card key={post.id} className="flex flex-col justify-between border-border/40 hover:border-border/60 transition-colors">
<CardHeader>
<div className="flex justify-between items-start">
<CardTitle className="text-xl text-primary">{post.title}</CardTitle>
</div>
<CardDescription className="mt-2">{post.description}</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
{strings.recentPostsPublishedOnLabel} {formatDate(post.date)}
</p>
</CardContent>
<CardFooter className="flex justify-end">
<Link
href={`/posts/${post.slug}`}
className="text-sm font-medium text-primary hover:underline"
>
{strings.recentPostsReadMoreFromLinkText}
</Link>
</CardFooter>
</Card>
))}
</div>
)}
</div>
)
}

View File

@ -1,8 +1,9 @@
import './globals.css' import "./globals.css"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { GeistSans } from 'geist/font/sans'; import { GeistSans } from "geist/font/sans"
import { Navbar } from "@/components/Navbar" import { Navbar } from "@/components/navigation/Navbar"
import { Sidebar } from "@/components/Sidebar" import { SidebarProvider } from "@/context/SidebarContext"
import ClientNav from "@/components/navigation/ClientNav"
export default function RootLayout({ export default function RootLayout({
children, children,
@ -10,24 +11,19 @@ export default function RootLayout({
children: React.ReactNode children: React.ReactNode
}) { }) {
return ( return (
<html <html lang="en" className={cn("bg-background font-sans antialiased", GeistSans.className)} suppressHydrationWarning>
lang="en"
className={cn(
"bg-background font-sans antialiased",
GeistSans.className
)}
suppressHydrationWarning
>
<body className="min-h-screen bg-background font-sans antialiased"> <body className="min-h-screen bg-background font-sans antialiased">
<div className="relative flex min-h-screen flex-col"> <SidebarProvider>
<Navbar /> <div className="relative flex min-h-screen flex-col">
<div className="flex-1 items-start md:grid md:grid-cols-[220px_minmax(0,1fr)] md:gap-6 lg:grid-cols-[240px_minmax(0,1fr)] lg:gap-10"> <Navbar />
<Sidebar /> <div className="flex-1 items-start md:grid md:grid-cols-[220px_minmax(0,1fr)] md:gap-6 lg:grid-cols-[240px_minmax(0,1fr)] lg:gap-10">
<main className="relative flex w-full flex-col overflow-hidden px-6 pr-7 py-6 sm:px-8 sm:pr-13 md:px-14 md:pr-7 lg:px-16 lg:pr-11"> <ClientNav />
{children} <main className="relative flex w-full flex-col overflow-hidden px-6 pr-7 py-6 sm:px-8 sm:pr-13 md:px-14 md:pr-7 lg:px-16 lg:pr-11">
</main> {children}
</main>
</div>
</div> </div>
</div> </SidebarProvider>
</body> </body>
</html> </html>
) )

View File

@ -1,75 +1,107 @@
'use client';
import { useEffect, useState } from 'react';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import Link from "next/link" import Link from "next/link"
import strings from "@/strings.json" import strings from "@/strings.json"
import { formatDistanceToNow, format } from 'date-fns'
const posts = [ type Post = {
{ id: number;
id: 1, title: string;
title: "Sample Post 1", description: string;
description: "Description", category: string;
date: "2025-01-14", date: number;
category: "Example Category 1", slug: string;
slug: "sample-post-1", };
},
{
id: 2,
title: "Sample Post 2",
description: "Description",
date: "2025-01-14",
category: "Example Category 1",
slug: "sample-post-2",
},
{
id: 3,
title: "Sample Post 3",
description: "Description",
date: "2025-01-14",
category: "Example Category 2",
slug: "sample-post-3",
},
{
id: 4,
title: "Sample Post 4",
description: "Description",
date: "2025-01-14",
category: "Example Category 2",
slug: "sample-post-4",
},
]
export default function Home() { export default function Home() {
const [posts, setPosts] = useState<{ id: number; title: string; description: string; category: string; date: number; slug: string; }[]>([]);
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
useEffect(() => {
console.log("[i] Fetching post list...");
(async () => {
try {
const res = await fetch(`http://localhost:3001/api/posts/fetchList`, {
method: 'GET',
});
if (!res.ok) {
throw new Error(`Failed to fetch post list: ${res.status}`);
}
const data = await res.json();
if (data.success === false) {
if (data.message) {
throw new Error(data.message);
} else {
throw new Error('Unknown error occurred');
}
} else {
const sortedPosts: Post[] = data.posts.sort((a: Post, b: Post) => b.date - a.date);
setPosts(sortedPosts);
}
} catch (error) {
console.error('[!] Error fetching post list:', error);
setError('Failed to fetch post list. Please try again later.');
} finally {
setLoading(false);
}
})();
}, []);
const formatDate = (timestamp: number): string => {
const date = new Date(timestamp * 1000);
const now = new Date();
if (date.getFullYear() !== now.getFullYear()) {
return format(date, 'd MMMM, yyyy');
} else {
return formatDistanceToNow(date, { addSuffix: true });
}
};
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<h1 className="text-4xl font-bold text-primary">{strings.latestPostsHeader}</h1> <h1 className="text-4xl font-bold text-primary">{strings.latestPostsHeader}</h1>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3"> {loading ? (
{posts.map((post) => ( <div className="flex items-center justify-center h-[80vh]">
<Card key={post.id} className="flex flex-col justify-between border-border/40 hover:border-border/60 transition-colors"> <div className="animate-spin rounded-full h-16 w-16 border-4 border-t-slate-800 border-white"></div>
<CardHeader> </div>
<div className="flex justify-between items-start"> ) : error ? (
<CardTitle className="text-xl text-primary">{post.title}</CardTitle> <div className="text-red-500">{error}</div>
<Badge variant="secondary" className="ml-2"> ) : (
{post.category} <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
</Badge> {posts.map((post) => (
</div> <Card key={post.id} className="flex flex-col justify-between">
<CardDescription className="mt-2">{post.description}</CardDescription> <CardHeader>
</CardHeader> <div className="flex justify-between items-start">
<CardContent> <CardTitle className="text-xl text-primary">{post.title}</CardTitle>
<p className="text-sm text-muted-foreground"> <Badge variant="secondary" className="ml-2">
{strings.recentPostsPublishedOnLabel} {post.date} {post.category}
</p> </Badge>
</CardContent> </div>
<CardFooter className="flex justify-end"> <CardDescription className="mt-2">{post.description}</CardDescription>
<Link </CardHeader>
href={`/posts/${post.slug}`} <CardContent>
className="text-sm font-medium text-primary hover:underline" <p className="text-sm text-muted-foreground">
> {strings.recentPostsPublishedOnLabel} {formatDate(post.date)}
{strings.recentPostsReadMoreFromLinkText} </p>
</Link> </CardContent>
</CardFooter> <CardFooter className="flex justify-end">
</Card> <Link
))} href={`/posts/${post.slug}`}
</div> className="text-sm font-medium text-primary hover:underline"
>
{strings.recentPostsReadMoreFromLinkText}
</Link>
</CardFooter>
</Card>
))}
</div>
)}
</div> </div>
) )
} }

152
app/posts/[slug]/page.tsx Normal file
View File

@ -0,0 +1,152 @@
'use client';
import { useState, useEffect } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { useParams } from 'next/navigation';
import Image from 'next/image';
import { Tag } from 'lucide-react';
export default function PostPage() {
const [markdown, setMarkdown] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
const [postTitle, setPostTitle] = useState('');
const [postDate, setPostDate] = useState('');
const [postCategory, setPostCategory] = useState('');
const { slug } = useParams();
useEffect(() => {
interface PostData {
title?: string;
date?: string;
category?: string;
message?: string;
}
function setPostData(postData: PostData) {
setPostTitle(postData.title || 'Untitled Post');
if (postData.date) {
console.log("[i] Date:", postData.date);
const date = new Date(Number(postData.date) * 1000)
console.log("[i] Date object:", date);
const options: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'long', day: 'numeric' };
console.log("[i] Date options:", options);
console.log("[i] Formatted date:", date.toLocaleDateString(undefined, options));
setPostDate(date.toLocaleDateString(undefined, options));
} else {
setPostDate('an unknown date');
}
if (postData.category) {
setPostCategory(postData.category);
} else if (postData.message) {
setPostCategory(postData.message);
} else {
setPostCategory("Error");
}
}
console.log("[i] Navigated to slug: ", slug);
(async () => {
try {
const res = await fetch(`http://localhost:3001/api/posts/get/${slug}`, {
cache: 'no-store',
});
const contentType = res.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
const data = await res.json();
if (data.success === false) {
if (data.message) {
setLoading(false);
setError(data.message);
throw new Error(data.message);
} else {
setLoading(false);
setError('Unknown error occurred');
throw new Error('Unknown error occurred');
}
}
} else {
const text = await res.text();
const catRes = await fetch(`http://localhost:3001/api/posts/getPostDetails/${slug}`, {
cache: 'no-store',
});
const postData = await catRes.json();
if (postData.success) {
setPostData(postData);
} else {
if (postData.message) {
setPostData(postData);
} else {
setPostCategory("Error");
}
}
setMarkdown(text);
setLoading(false);
}
} catch (error) {
console.error('Error fetching post:', error);
setError('Could not load the post.');
setMarkdown('# Error\n\nCould not load the post.');
}
})();
}, [slug]);
if (loading) {
return (
<div className="flex items-center justify-center h-[80vh]">
<div className="animate-spin rounded-full h-16 w-16 border-4 border-t-slate-800 border-white"></div>
</div>
);
}
if (error) {
return (
<div className="text-red-500">{error}</div>
);
}
return (
<div>
<h1 className="text-4xl font-bold my-4">{postTitle}</h1>
<p className="italic text-muted-foreground">Published on {postDate}</p>
<div className="flex flex-wrap gap-2 my-4">
<span className="border border-white text-bold px-3 py-1 rounded-md">
<Tag className="w-4 h-4 inline-block mr-1" /> {postCategory}
</span>
</div>
<hr className="my-4 border-white" />
<ReactMarkdown remarkPlugins={[remarkGfm]} components={{
h1: ({...props}) => <h1 className='text-4xl font-bold my-4' {...props}/>,
h2: ({...props}) => <h2 className='text-2xl font-semibold my-4' {...props}/>,
h3: ({...props}) => <h3 className='text-xl my-4' {...props}/>,
h4: ({...props}) => <h4 className='text-md my-4' {...props}/>,
h5: ({...props}) => <h5 className='text-sm my-4' {...props}/>,
em: ({...props}) => <em className='mt-30' {...props}/>,
hr: ({...props}) => <hr className='border-white solid my-5' {...props}/>,
p: ({...props}) => <p className='my-3' {...props}/>,
a: ({...props}) => <a className='text-white underline' {...props}/>,
img: ({src, alt, width, height, ...props}) => <Image className='my-4 rounded-md' src={src || ''} alt={alt || ''} width={width ? parseInt(width.toString()) : 800} height={height ? parseInt(height.toString()) : 400} {...props}/>,
blockquote: ({...props}) => <blockquote className="border-l-4 border-white pl-4 my-4 italic" {...props}/>,
code: ({...props}) => <code className="bg-gray-800 rounded px-1 font-mono text-sm" {...props}/>,
pre: ({...props}) => <pre className="bg-gray-800 rounded p-4 my-4 overflow-x-auto font-mono text-sm" {...props}/>,
table: ({...props}) => <table className="border-collapse table-auto w-full my-4" {...props}/>,
thead: ({...props}) => <thead className="bg-gray-800" {...props}/>,
th: ({...props}) => <th className="border border-gray-600 px-4 py-2" {...props}/>,
td: ({...props}) => <td className="border border-gray-600 px-4 py-2" {...props}/>,
ul: ({...props}) => <ul className="list-disc ml-6 my-2" {...props}/>,
ol: ({...props}) => <ol className="list-decimal ml-6 my-2" {...props}/>,
li: ({...props}) => <li className="pl-2" {...props}/>,
input: ({type, ...props}) => type === 'checkbox' ?
<input type="checkbox" className="ml-[0.4rem] mr-2" {...props}/> :
<input type={type} {...props}/>
}}>
{markdown}
</ReactMarkdown>
</div>
);
}

BIN
bun.lockb

Binary file not shown.

View File

@ -1,141 +0,0 @@
"use client"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"
import { Menu } from 'lucide-react'
import { DialogTitle } from "@radix-ui/react-dialog"
import { VisuallyHidden } from "@radix-ui/react-visually-hidden"
import strings from "@/strings.json"
import config from "@/config.json"
export function Navbar() {
const pathname = usePathname()
return (
<header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container flex h-14 items-center px-4 sm:px-6 lg:px-8">
<div className="mr-4 hidden md:flex">
<nav className="flex items-center space-x-6 text-sm font-medium">
<Link
href="/"
className={cn(
"transition-colors hover:text-primary",
pathname === "/" ? "text-primary" : "text-muted-foreground"
)}
>
{strings.homeLinkTextNavbar}
</Link>
<Link
href="/categories"
className={cn(
"transition-colors hover:text-primary",
pathname?.startsWith("/categories")
? "text-primary"
: "text-muted-foreground"
)}
>
{strings.categoriesLinkTextNavbar}
</Link>
<Link
href="/admin"
className={cn(
"transition-colors hover:text-primary",
pathname?.startsWith("/admin")
? "text-primary"
: "text-muted-foreground"
)}
>
{strings.adminLinkTextNavbar}
</Link>
{config.personalWebsite && (
<Link
href={config.personalWebsiteUrl}
className={cn(
"transition-colors hover:text-primary",
pathname?.startsWith("/about")
? "text-primary"
: "text-muted-foreground"
)}
>
{config.personalWebsiteLinkText}
</Link>
)}
</nav>
</div>
<Sheet>
<SheetTrigger asChild>
<Button
variant="ghost"
className="mr-4 px-0 text-base hover:bg-transparent focus-visible:bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 md:hidden"
>
<Menu className="h-5 w-5" />
<span className="sr-only">Toggle Menu</span>
</Button>
</SheetTrigger>
<SheetContent side="left" className="pr-0">
<DialogTitle>
<VisuallyHidden>Menu</VisuallyHidden>
</DialogTitle>
<div className="my-4 h-[calc(100vh-8rem)] pb-10 pl-6">
<div className="flex flex-col space-y-3">
<MobileLink href="/" onOpenChange={() => {}}>
Home
</MobileLink>
<MobileLink href="/categories" onOpenChange={() => {}}>
Categories
</MobileLink>
<MobileLink href="/about" onOpenChange={() => {}}>
About
</MobileLink>
</div>
</div>
</SheetContent>
</Sheet>
<div className="flex flex-1 items-center justify-start space-x-2 md:justify-center">
<div className="w-full flex-1 md:w-auto md:flex-none">
<Input
placeholder="Search posts..."
className="h-9 w-full md:hidden lg:hidden"
/>
</div>
</div>
</div>
</header>
)
}
interface MobileLinkProps extends React.ComponentPropsWithoutRef<typeof Link> {
onOpenChange?: (open: boolean) => void
children: React.ReactNode
}
function MobileLink({
href,
onOpenChange,
className,
children,
...props
}: MobileLinkProps) {
const pathname = usePathname()
return (
<Link
href={href}
onClick={() => {
onOpenChange?.(false)
}}
className={cn(
"text-muted-foreground transition-colors hover:text-primary",
pathname === href && "text-primary",
className
)}
{...props}
>
{children}
</Link>
)
}

View File

@ -1,99 +0,0 @@
import Link from "next/link"
import { Input } from "@/components/ui/input"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import strings from "@/strings.json"
const posts = [
{
id: 1,
title: "Sample Post 1",
description: "Description",
date: "2025-01-14",
category: "Example Category 1",
slug: "sample-post-1",
},
{
id: 2,
title: "Sample Post 2",
description: "Description",
date: "2025-01-14",
category: "Example Category 1",
slug: "sample-post-2",
},
{
id: 3,
title: "Sample Post 3",
description: "Description",
date: "2025-01-14",
category: "Example Category 2",
slug: "sample-post-3",
},
{
id: 4,
title: "Sample Post 4",
description: "Description",
date: "2025-01-14",
category: "Example Category 2",
slug: "sample-post-4",
},
]
const uniqueCategories = Array.from(new Set(posts.map(post => post.category)));
const latestPosts = posts.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()).slice(0, 3);
export function Sidebar() {
return (
<aside className="fixed left-3 md:left-2 lg:left-5 top-15 z-30 hidden h-[calc(100vh-3.5rem)] w-full shrink-0 overflow-y-auto bg-background px-4 py-6 md:sticky md:block md:w-[280px] lg:w-[300px]">
<div className="flex items-center justify-between mb-6">
<Link href="/" className="text-4xl font-bold text-primary">{process.env.BLOG_NAME || 'BlogPop'}</Link>
</div>
<div className="flex flex-1 items-center justify-start space-x-2 md:justify-center">
<div className="w-full flex-1 md:w-auto md:flex-none">
<Input
placeholder="Search posts..."
className="h-9 mb-8 w-full md:w-[250px] lg:w-[270px]"
/>
</div>
</div>
<Card className="mb-6">
<CardHeader>
<CardTitle>{strings.recentPostsLabelSidebar}</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2">
{latestPosts.map((post) => (
<li key={post.id}>
<Link
href={`/posts/${post.slug}`}
className="text-sm text-muted-foreground hover:text-primary"
>
{post.title}
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{strings.categoriesLabelSidebar}</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2">
{uniqueCategories.map((category) => (
<li key={category}>
<Link
href={`/categories/${category.toLowerCase()}`}
className="text-sm text-muted-foreground hover:text-primary"
>
{category}
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</aside>
)
}

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState } from "react" import { useState, useEffect } from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
@ -19,9 +19,62 @@ export function LoginForm({
}: React.ComponentPropsWithoutRef<"div">) { }: React.ComponentPropsWithoutRef<"div">) {
const [username, setUsername] = useState("") const [username, setUsername] = useState("")
const [password, setPassword] = useState("") const [password, setPassword] = useState("")
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const validate = async () => {
const cookies = document.cookie.split(';').reduce((acc, cookie) => {
const [name, value] = cookie.split('=').map(c => c.trim());
acc[name] = value;
return acc;
}, {} as Record<string, string>);
if (cookies.key) {
console.log('[i] Key found in browser cookies, checking validity');
try {
const response = await fetch('http://localhost:3001/api/admin/validate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ key: cookies.key, username: cookies.username }),
})
if (!response.ok) {
console.log('[!] Failed to check key, skipping validation and clearing cookie');
document.cookie = 'key=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';
} else {
const data = await response.json()
if (data.valid) {
// key exists and is valid, user has no reason to use login
console.log("[✓] Key is valid, redirecting to admin panel");
window.location.href = '/admin';
} else {
// key exists, but the server reports its not the latest key
console.log("[!] Key is invalid, clearing cookie and redirecting to login");
document.cookie.split(";").forEach((cookie) => {
document.cookie = cookie
.replace(/^ +/, "")
.replace(/=.*/, "=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/");
});
window.location.reload();
}
}
} catch (error) {
console.error('[!]', error)
setError('Failed to connect to the server. Please try again later.')
}
}
};
if (typeof window !== 'undefined') {
validate();
}
}, []);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
setError(null)
try { try {
const response = await fetch('http://localhost:3001/api/admin/login', { const response = await fetch('http://localhost:3001/api/admin/login', {
method: 'POST', method: 'POST',
@ -32,18 +85,31 @@ export function LoginForm({
}) })
if (!response.ok) { if (!response.ok) {
console.log('Failed to login'); console.log('[!] Failed to login');
setError('An unknown error occurred. Please try again later.')
} else {
const data = await response.json()
if (data.success && data.key) {
console.log("[✓] Login successful, redirecting to admin panel");
document.cookie = `key=${data.key}; path=/; secure; samesite=strict`;
document.cookie = `username=${username}; path=/; secure; samesite=strict`;
document.location.href = '/admin';
} else {
if (!data.success && data.message) {
setError(data.message)
} else {
setError('An unknown error occurred. Please try again later.')
}
}
} }
const data = await response.json()
console.log('Success:', data)
} catch (error) { } catch (error) {
console.error('Error:', error) console.error('[i]', error)
setError('Failed to connect to the server. Please try again later.')
} }
} }
return ( return (
<div className={cn("flex flex-col gap-6", className)} {...props}> <div className={cn("relative flex min-h-screen flex-col", className)} {...props}>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-2xl">Administration Panel</CardTitle> <CardTitle className="text-2xl">Administration Panel</CardTitle>
@ -52,6 +118,7 @@ export function LoginForm({
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{error && <div className="text-red-500 mb-6">{error}</div>}
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="grid gap-2"> <div className="grid gap-2">

View File

@ -0,0 +1,29 @@
"use client"
import { usePathname } from "next/navigation"
import { useEffect, useState } from "react"
import { Sidebar } from "@/components/navigation/Sidebar"
import { AdminSidebar } from "@/components/navigation/sidebar/AdminSidebar"
import { MobileSidebar } from "@/components/navigation/sidebar/MobileSidebar"
import { MobileAdminSidebar } from "@/components/navigation/sidebar/MobileAdminSidebar"
export default function ClientSideNav() {
const pathname = usePathname()
const [isMobile, setIsMobile] = useState(false)
const isAdmin = pathname.includes("admin") && !pathname.includes("login")
useEffect(() => {
const handleResize = () => {
setIsMobile(window.innerWidth < 768)
}
handleResize()
window.addEventListener("resize", handleResize)
return () => window.removeEventListener("resize", handleResize)
}, [])
if (isMobile) {
return isAdmin ? <MobileAdminSidebar /> : <MobileSidebar />
}
return isAdmin ? <AdminSidebar /> : <Sidebar />
}

View File

@ -0,0 +1,62 @@
"use client"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Menu } from "lucide-react"
import { useSidebar } from "@/context/SidebarContext"
import strings from "@/strings.json"
import config from "@/config.json"
export function Navbar() {
const pathname = usePathname()
const { toggleSidebar } = useSidebar()
return (
<header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container flex h-14 items-center px-4 sm:px-6 lg:px-8">
<div className="mr-4 hidden md:flex">
<nav className="flex items-center space-x-6 text-sm font-medium">
<Link
href="/"
className={cn(
"transition-colors hover:text-primary",
pathname === "/" ? "text-primary" : "text-muted-foreground",
)}
>
{strings.homeLinkTextNavbar}
</Link>
<Link
href="/categories"
className={cn(
"transition-colors hover:text-primary",
pathname?.startsWith("/categories") ? "text-primary" : "text-muted-foreground",
)}
>
{strings.categoriesLinkTextNavbar}
</Link>
{config.personalWebsite && (
<Link href={config.personalWebsiteUrl} className="transition-colors text-muted-foreground hover:text-primary">
{config.personalWebsiteLinkText}
</Link>
)}
</nav>
</div>
<div className="flex flex-1 items-center justify-start space-x-2 md:justify-center">
<div className="flex items-center space-x-2">
<Button variant="ghost" size="icon" className="md:hidden lg:hidden" onClick={toggleSidebar}>
<Menu className="h-5 w-5" />
<span className="sr-only">Toggle sidebar</span>
</Button>
</div>
<div className="w-full flex-1 md:w-auto md:flex-none">
<Input placeholder="Search posts on desktop..." className="h-9 w-full md:hidden lg:hidden" />
</div>
</div>
</div>
</header>
)
}

View File

@ -0,0 +1,140 @@
'use client';
import { useState, useEffect } from 'react';
import Link from "next/link";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import strings from "@/strings.json";
type Post = {
id: string;
title: string;
slug: string;
category: string;
date: string;
};
export function Sidebar() {
const [posts, setPosts] = useState<Post[]>([]);
const [error, setError] = useState('');
const [loadingPosts, setLoadingPosts] = useState(true);
const [loadingCategories, setLoadingCategories] = useState(true);
const [uniqueCategories, setUniqueCategories] = useState<{ name: string; slug: string }[]>([]);
useEffect(() => {
console.log("[i] Fetching post list...");
fetch('http://localhost:3001/api/posts/fetchList')
.then(response => response.json())
.then(data => {
if (!data.posts) {
throw new Error('[!] Failed to fetch posts');
}
console.log("[✓] Fetched posts");
setPosts(data.posts);
setLoadingPosts(false);
})
.catch(error => {
console.error(error);
setError(`[!] Error fetching posts: ${error.message}`);
setLoadingPosts(false);
});
console.log("[i] Fetching category list...");
fetch('http://localhost:3001/api/categories/fetchList')
.then(response => response.json())
.then(data => {
if (!data.categories) {
throw new Error('Failed to fetch categories');
}
console.log("[✓] Fetched categories");
const categories = data.categories.map((cat: { name: string, slug: string }) => ({
name: cat.name,
slug: cat.slug,
}));
setUniqueCategories(categories);
setLoadingCategories(false);
})
.catch(error => {
console.error(error);
setError(`[!] Error fetching categories: ${error.message}`);
setLoadingCategories(false);
});
}, []);
return (
<aside className="fixed left-3 md:left-2 lg:left-5 top-15 z-30 hidden h-[calc(100vh-3.5rem)] w-full shrink-0 overflow-y-auto bg-background px-4 py-6 md:sticky md:block md:w-[280px] lg:w-[300px]">
<div className="flex items-center justify-between mb-6">
<Link href="/" className="text-4xl font-bold text-primary">{process.env.NEXT_PUBLIC_BLOG_NAME || 'BlogPop'}</Link>
</div>
<div className="flex flex-1 items-center justify-start space-x-2 md:justify-center">
<div className="w-full flex-1 md:w-auto md:flex-none">
<Input
placeholder="Search posts..."
className="h-9 mb-8 w-full md:w-[250px] lg:w-[270px]"
/>
</div>
</div>
<Card className="mb-6">
<CardHeader>
<CardTitle>{strings.recentPostsLabelSidebar}</CardTitle>
</CardHeader>
<CardContent>
{loadingPosts ? (
<div className="flex items-center justify-center h-[10vh]">
<div className="animate-spin rounded-full h-16 w-16 border-4 border-t-slate-800 border-white"></div>
</div>
) : error ? (
<div className="text-red-500">{error}</div>
) : posts.length === 0 ? (
<div>No recent posts available.</div>
) : (
<ul className="space-y-2">
{posts
.sort((a, b) => parseInt(b.date) - parseInt(a.date))
.slice(0, 3)
.map((post) => (
<li key={post.id}>
<Link
href={`/posts/${post.slug}`}
className="text-sm text-muted-foreground hover:text-primary"
aria-label={`View post titled ${post.title}`}
>
{post.title}
</Link>
</li>
))}
</ul>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{strings.categoriesLabelSidebar}</CardTitle>
</CardHeader>
<CardContent>
{loadingCategories ? (
<div className="flex items-center justify-center h-[10vh]">
<div className="animate-spin rounded-full h-16 w-16 border-4 border-t-slate-800 border-white"></div>
</div>
) : uniqueCategories.length === 0 ? (
<div>No categories available.</div>
) : (
<ul className="space-y-2">
{uniqueCategories.map((category) => (
<li key={category.slug}>
<Link
href={`/category/${category.slug}`}
className="text-sm text-muted-foreground hover:text-primary"
aria-label={`View posts in category ${category.name}`}
>
{category.name}
</Link>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</aside>
);
}

View File

@ -0,0 +1,44 @@
import Link from "next/link"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
//import strings from "@/strings.json"
export function AdminSidebar() {
return (
<aside className="fixed left-3 md:left-2 lg:left-5 top-15 z-30 hidden h-[calc(100vh-3.5rem)] w-full shrink-0 overflow-y-auto bg-background px-4 py-6 md:sticky md:block md:w-[280px] lg:w-[300px]">
<div className="flex items-center justify-between mb-8">
<Link href="/admin" className="text-4xl font-bold text-primary">BlogPop</Link>
<span className="text-sm text-muted-foreground">v1.0.0</span>
</div>
<Card className="mb-6">
<CardHeader>
<CardTitle>Navigation</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2">
<li>
<Link href="/admin" className="text-sm text-muted-foreground hover:text-primary">
Dashboard
</Link>
</li>
<li>
<Link href="/admin/posts" className="text-sm text-muted-foreground hover:text-primary">
Posts
</Link>
</li>
<li>
<Link href="/admin/users" className="text-sm text-muted-foreground hover:text-primary">
Users
</Link>
</li>
<li>
<Link href="/admin/logout" className="text-sm text-muted-foreground hover:text-primary">
Logout
</Link>
</li>
</ul>
</CardContent>
</Card>
</aside>
)
}

View File

@ -0,0 +1,43 @@
"use client"
import Link from "next/link"
import { Button } from "@/components/ui/button"
import { X } from "lucide-react"
import { useSidebar } from "@/context/SidebarContext"
export function MobileAdminSidebar() {
const { isOpen, toggleSidebar } = useSidebar()
return (
<aside
className={`fixed inset-y-0 left-0 z-30 w-64 transform overflow-y-auto bg-background px-4 py-6 transition-transform duration-300 ease-in-out ${
isOpen ? "translate-x-0" : "-translate-x-full"
}`}
>
<div className="flex items-center justify-between mb-6 mt-12">
<Link href="/" className="text-4xl font-bold text-primary">
{process.env.BLOG_NAME || "BlogPop"}
</Link>
<Button variant="ghost" size="icon" onClick={toggleSidebar} className="md:hidden">
<X className="h-6 w-6" />
<span className="sr-only">Close sidebar</span>
</Button>
</div>
<nav className="flex flex-col space-y-4">
<Link href="/admin" className="text-lg text-muted-foreground hover:text-primary">
Dashboard
</Link>
<Link href="/admin/posts" className="text-lg text-muted-foreground hover:text-primary">
Posts
</Link>
<Link href="/admin/users" className="text-lg text-muted-foreground hover:text-primary">
Users
</Link>
<Link href="/admin/logout" className="text-lg text-muted-foreground hover:text-primary">
Logout
</Link>
</nav>
</aside>
)
}

View File

@ -0,0 +1,46 @@
"use client"
import Link from "next/link"
import { Button } from "@/components/ui/button"
import { X } from "lucide-react"
import { useSidebar } from "@/context/SidebarContext"
import config from "@/config.json"
export function MobileSidebar() {
const { isOpen, toggleSidebar } = useSidebar()
return (
<aside
className={`fixed inset-y-0 left-0 z-30 w-64 transform overflow-y-auto bg-background px-4 py-6 transition-transform duration-300 ease-in-out ${
isOpen ? "translate-x-0" : "-translate-x-full"
}`}
>
<div className="flex items-center justify-between mb-6 mt-12">
<Link href="/" className="text-4xl font-bold text-primary">
{process.env.BLOG_NAME || "BlogPop"}
</Link>
<Button variant="ghost" size="icon" onClick={toggleSidebar} className="md:hidden">
<X className="h-6 w-6" />
<span className="sr-only">Close sidebar</span>
</Button>
</div>
<nav className="flex flex-col space-y-4">
<Link href="/" className="text-lg text-muted-foreground hover:text-primary">
Home
</Link>
<Link href="/categories" className="text-lg text-muted-foreground hover:text-primary">
Categories
</Link>
<Link href="/contact" className="text-lg text-muted-foreground hover:text-primary">
Contact
</Link>
{config.personalWebsite && (
<Link href={config.personalWebsiteUrl} className="text-lg text-muted-foreground hover:text-primary">
{config.personalWebsiteLinkText}
</Link>
)}
</nav>
</aside>
)
}

View File

@ -0,0 +1,30 @@
"use client"
import type React from "react"
import { createContext, useContext, useState } from "react"
interface SidebarContextType {
isOpen: boolean
toggleSidebar: () => void
}
const SidebarContext = createContext<SidebarContextType | undefined>(undefined)
export function SidebarProvider({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false)
const toggleSidebar = () => {
setIsOpen((prev) => !prev)
}
return <SidebarContext.Provider value={{ isOpen, toggleSidebar }}>{children}</SidebarContext.Provider>
}
export function useSidebar() {
const context = useContext(SidebarContext)
if (context === undefined) {
throw new Error("useSidebar must be used within a SidebarProvider")
}
return context
}

View File

@ -4,6 +4,14 @@ const nextConfig: NextConfig = {
env: { env: {
BLOG_NAME: process.env.BLOG_NAME, BLOG_NAME: process.env.BLOG_NAME,
}, },
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '**',
},
],
},
}; };
export default nextConfig; export default nextConfig;

View File

@ -18,24 +18,29 @@
"@uiw/react-md-editor": "^4.0.5", "@uiw/react-md-editor": "^4.0.5",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0",
"geist": "^1.3.1", "geist": "^1.3.1",
"is-mobile": "^5.0.0",
"lucide-react": "^0.471.1", "lucide-react": "^0.471.1",
"next": "15.1.4", "next": "15.1.4",
"next-themes": "^0.4.4", "next-themes": "^0.4.4",
"react": "^19.0.0", "react": "^19.0.0",
"react-device-detect": "^2.2.3",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-markdown": "^9.0.3",
"remark-gfm": "^4.0.0",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7" "tailwindcss-animate": "^1.0.7"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5", "typescript": "^5.7.3",
"@types/node": "^20", "@types/node": "^20.17.13",
"@types/react": "^19", "@types/react": "^19.0.7",
"@types/react-dom": "^19", "@types/react-dom": "^19.0.3",
"postcss": "^8", "postcss": "^8.5.1",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.17",
"eslint": "^9", "eslint": "^9.18.0",
"eslint-config-next": "15.1.4", "eslint-config-next": "15.1.4",
"@eslint/eslintrc": "^3" "@eslint/eslintrc": "^3.2.0"
} }
} }

View File

@ -2,6 +2,10 @@ import express from 'express';
import sqlite3 from 'sqlite3'; import sqlite3 from 'sqlite3';
import { open } from 'sqlite'; import { open } from 'sqlite';
import cors from 'cors'; import cors from 'cors';
import crypto from 'crypto';
import figlet from 'figlet';
import fs from 'fs';
import http from 'http';
const app = express(); const app = express();
const port = 3001; const port = 3001;
@ -26,7 +30,8 @@ let db;
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT, username TEXT,
email TEXT, email TEXT,
password TEXT password TEXT,
key TEXT
); );
`); `);
@ -41,37 +46,254 @@ let db;
date TEXT date TEXT
); );
`); `);
await db.exec(`
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT,
description TEXT,
slug TEXT
);
`);
})(); })();
app.use(express.json()); app.use(express.json());
async function validateUser(key, username, isRoute) {
if (isRoute) {
try {
const user = await db.get('SELECT * FROM users WHERE key = ? AND username = ?', [key, username]);
if (user) {
return { success: true, user };
} else {
return { success: false, message: 'Your session has failed verification, please try again.' };
}
} catch (error) {
console.error('[!] Error validating user:', error);
return { success: false, message: 'Server error' };
}
} else {
try {
const user = await db.get('SELECT * FROM users WHERE key = ? AND username = ?', [key, username]);
if (user) {
return true;
} else {
return false;
}
} catch (error) {
console.error('[!] Error validating user:', error);
return false;
}
}
}
app.get('/api/posts/get/:slug', async (req, res) => {
const { slug } = req.params;
console.log("[i] Received request for post with slug", slug);
try {
const post = await db.get('SELECT * FROM posts WHERE slug = ?', [slug]);
if (post) {
console.log("[i] Found post entry in database with slug", slug);
const md = post.content;
if (md === null) {
console.log("[!] Post does not have any content");
res.json({ success: false, message: 'Post not found' });
} else {
console.log("[✓] Sending post content to client");
res.send(md);
}
} else {
console.log("[X] Post not found in database");
res.json({ success: false, message: 'Post not found' });
}
} catch (error) {
console.error('[!] Error fetching post:', error);
res.json({ success: false, message: 'Server error' });
}
});
app.post('/api/admin/validate', async (req, res) => {
const { key, username } = req.body;
console.log("[i] Received key validation request for", username);
const result = await validateUser(key, username, true);
if (result.success) {
console.log("[✓] User validated");
res.json({ success: true });
} else {
console.log("[X] User validation failed");
res.json(result);
}
});
app.post('/api/admin/posts/totalPosts', async (req, res) => {
const { key, username } = req.body;
console.log("[i] Received request to count total posts from", username);
const isValid = await validateUser(key, username, false);
if (!isValid) {
console.log("[X] User validation failed");
return res.json({ message: 'Invalid credentials' });
}
try {
const result = await db.get('SELECT COUNT(*) as count FROM posts');
console.log("[✓] Total posts counted:", result.count);
res.json({ count: result.count });
} catch (error) {
console.error('[!] Error counting posts:', error);
res.json({ message: 'Server error' });
}
});
app.post('/api/admin/users/totalUsers', async (req, res) => {
const { key, username } = req.body;
console.log("[i] Received request to count total users from", username);
const isValid = await validateUser(key, username, false);
if (!isValid) {
console.log("[X] User validation failed");
return res.json({ message: 'Invalid credentials' });
}
try {
const result = await db.get('SELECT COUNT(*) as count FROM users');
console.log("[✓] Total users counted:", result.count);
res.json({ count: result.count });
} catch (error) {
console.error('[!] Error counting users:', error);
res.json({ message: 'Server error' });
}
});
app.post('/api/admin/login', async (req, res) => { app.post('/api/admin/login', async (req, res) => {
const { username, password } = req.body; const { username, password } = req.body;
const key = crypto.randomBytes(32).toString('hex');
try { try {
const user = await db.get('SELECT * FROM users WHERE username = ? AND password = ?', [username, password]); const user = await db.get('SELECT * FROM users WHERE username = ? AND password = ?', [username, password]);
if (user) { if (user) {
res.json({ success: true, user }); console.log("[✓] User logged in:", username);
await db.run('UPDATE users SET key = ? WHERE username = ?', [key, username]);
console.log("[✓] Key updated for", username);
res.json({ success: true, user, key: key });
} else { } else {
res.status(401).json({ success: false, message: 'Invalid credentials' }); console.log("[X] Invalid credentials for", username);
res.json({ success: false, message: 'Incorrect username or password' });
} }
} catch (error) { } catch (error) {
console.error('Error logging in:', error); console.error('[!] Error logging in:', error);
res.status(500).json({ success: false, message: 'Server error' }); res.json({ success: false, message: 'Server error' });
} }
}); });
app.post('/api/users/add', async (req, res) => { app.post('/api/users/add', async (req, res) => {
const { username, email, password } = req.body; const { username, email, password, key } = req.body;
const result = await db.run('INSERT INTO users (username, email, password) VALUES (?, ?, ?)', [username, email, password]); console.log("[i] Received request to add user", username);
res.json({ id: result.lastID }); const result = await db.run('INSERT INTO users (username, email, password, key) VALUES (?, ?, ?, ?)', [username, email, password, key]);
if (result) {
console.log("[✓] User added successfully");
res.json({ id: result.lastID });
} else {
console.log("[X] Failed to add user");
res.json({ success: false, message: 'Failed to add user' });
}
}); });
app.post('/api/posts/new', async (req, res) => { app.post('/api/posts/new', async (req, res) => {
const { title, description, category, slug, content, date } = req.body; const { title, description, category, slug, content, date } = req.body;
console.log("[i] Received request to add new post:", title);
const result = await db.run('INSERT INTO posts (title, description, category, slug, content, date) VALUES (?, ?, ?, ?, ?, ?)', [title, description, category, slug, content, date]); const result = await db.run('INSERT INTO posts (title, description, category, slug, content, date) VALUES (?, ?, ?, ?, ?, ?)', [title, description, category, slug, content, date]);
res.json({ id: result.lastID }); if (result) {
console.log("[✓] Post added successfully");
res.json({ slug: result.slug });
} else {
console.log("[X] Failed to add post");
res.json({ success: false, message: 'Failed to add post' });
}
}); });
app.listen(port, () => { app.get('/api/posts/fetchList', async (req, res) => {
console.log(`Server running on http://localhost:${port}`); console.log("[i] Received request to fetch post list");
try {
const posts = await db.all('SELECT id, title, description, category, slug, date FROM posts');
console.log("[✓] Fetched post list");
res.json({ posts });
} catch (error) {
console.error('[!] Error fetching post list:', error);
res.json({ message: 'Server error' });
}
}); });
app.get('/api/categories/fetchList', async (req, res) => {
console.log("[i] Received request to fetch category list");
try {
const categories = await db.all('SELECT id, name, description, slug FROM categories');
console.log("[✓] Fetched category list");
res.json({ categories });
} catch (error) {
console.error('[!] Error fetching category list:', error);
res.json({ message: 'Server error' });
}
});
app.get('/api/posts/getPostDetails/:slug', async (req, res) => {
const { slug } = req.params;
console.log("[i] Received request to fetch post details for slug", slug);
try {
const postData = await db.get('SELECT title, date, category FROM posts WHERE slug = ?', [slug]);
if (!postData || !postData.title || !postData.date || !postData.category) {
console.log("[X] Failed to query post details not found for slug", slug);
return res.json({ success: false, message: 'Post data not found' });
}
console.log("[✓] Fetched post details for slug", slug);
res.json({ success: true, title: postData.title, date: postData.date, category: postData.category });
} catch (error) {
console.error('[!] Error fetching post details for ', slug, ': ', error);
res.json({ success: false });
}
});
async function checkFrontendStatus() { // please someone come up with a better name for this function
return new Promise((resolve) => {
http.get('http://localhost:3000', (res) => {
if (res.statusCode === 200) {
resolve("│ ONLINE │ :3000 │ Frontend │");
} else {
resolve("│ DOWN │ :3000 │ Frontend │");
}
}).on('error', () => {
resolve("│ DOWN │ :3000 │ Frontend │");
});
});
}
app.listen(port, async () => {
figlet("BlogPop", async function (err, data) {
if (err) {
console.log("┌─────────────ascii display failed─────────────┐");
console.log("│ BLOGPOP SERVER EDITION │");
console.log("├──────────────────────────────────────────────┤");
console.log("│ IF YOU PAID FOR THIS CODE, YOU WERE SCAMMED! │");
console.log("└──────────────────────────────────────────────┘");
} else {
console.log(data + "\n");
console.log("┌──────────────────────────────────────────────┐");
console.log("│ SERVER EDITION / BSD-3.0 │");
console.log("├──────────────────────────────────────────────┤");
console.log("│ IF YOU PAID FOR THIS CODE, YOU WERE SCAMMED! │")
console.log("└──────────────────────────────────────────────┘\n");
}
console.log("┌────────┬───────┬─────────────────────────────┐");
console.log("│ STATUS │ PORT │ SERVICE │");
console.log("├────────┼───────┼─────────────────────────────┤");
console.log("│ ONLINE │ :3001 │ Backend │");
const dbStatus = fs.existsSync('./db.sqlite') ? "ONLINE" : " DOWN ";
console.log(`${dbStatus} │ XXXXX │ Database │`);
const frontendStatus = await checkFrontendStatus();
console.log(frontendStatus);
console.log("└────────┴───────┴─────────────────────────────┘\n");
});
});

View File

@ -20,6 +20,8 @@
"cors": "^2.8.5", "cors": "^2.8.5",
"ejs": "^3.1.10", "ejs": "^3.1.10",
"express": "^4.21.2", "express": "^4.21.2",
"figlet": "^1.8.0",
"http": "^0.0.1-security",
"sqlite": "^5.1.1", "sqlite": "^5.1.1",
"sqlite3": "^5.1.7" "sqlite3": "^5.1.7"
}, },

View File

@ -53,5 +53,5 @@
"createPostButtonText": "Create Post", "createPostButtonText": "Create Post",
"createUserButtonText": "Create User", "createUserButtonText": "Create User",
"recentPostsPublishedOnLabel": "Published on" "recentPostsPublishedOnLabel": "Published "
} }