add functionality and create a very minimum viable product
This commit is contained in:
parent
cb02089224
commit
c47438c033
79
app/admin/layout.tsx
Normal file
79
app/admin/layout.tsx
Normal 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
20
app/admin/logout/page.tsx
Normal 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;
|
||||
}
|
@ -1,44 +1,121 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import strings from "@/strings.json"
|
||||
import { PlusCircle, UserPlus } from "lucide-react"
|
||||
import { PlusCircle, UserPlus, CircleAlert } from "lucide-react"
|
||||
|
||||
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",
|
||||
},
|
||||
]
|
||||
export default function Home() {
|
||||
const [totalPosts, setTotalPosts] = useState(0);
|
||||
const [postCardError, setPostCardError] = useState(false);
|
||||
const [postCardLoading, setPostCardLoading] = useState(true);
|
||||
const [totalUsers, setTotalUsers] = useState(0);
|
||||
const [userCtCardError, setUserCtCardError] = useState(false);
|
||||
const [userCtCardLoading, setUserCtCardLoading] = 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);
|
||||
}
|
||||
})();
|
||||
|
||||
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 (
|
||||
<div className="space-y-8">
|
||||
<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">
|
||||
<CardHeader>
|
||||
<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">
|
||||
{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>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</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>
|
||||
<CardTitle>Quick Actions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<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" />
|
||||
New Post
|
||||
</a>
|
||||
|
106
app/admin/posts/page.tsx
Normal file
106
app/admin/posts/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
@ -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 { UserPlus, CircleAlert } from "lucide-react"
|
||||
|
||||
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 (
|
||||
<div className="space-y-8">
|
||||
<h1 className="text-4xl font-bold text-primary">{strings.usersHeader}</h1>
|
||||
<div className="grid gap-6 sm:grid-cols-3 lg:grid-cols-4">
|
||||
<Card className="flex flex-col justify-between">
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-start">
|
||||
<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>
|
||||
<CardTitle>Quick Actions</CardTitle>
|
||||
</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 className="flex flex-col justify-between">
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-start">
|
||||
<CardDescription>
|
||||
<CardTitle className="text-xl text-primary">{strings.totalUsersLoggedInMonthCardTitle}</CardTitle>
|
||||
<p className="text-sm italic text-muted-foreground mt-1">{strings.totalUsersLoggedInMonthCardDescription}</p>
|
||||
</CardDescription>
|
||||
<CardTitle className="text-xl text-primary">{strings.totalUsersCardTitle}</CardTitle>
|
||||
<span className="text-4xl font-bold text-primary ml-2">
|
||||
{/* TODO: Implement users logged in (by month) logic + counter */}
|
||||
24
|
||||
{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>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
@ -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 { Badge } from "@/components/ui/badge"
|
||||
import Link from "next/link"
|
||||
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",
|
||||
},
|
||||
]
|
||||
interface Category {
|
||||
id: number
|
||||
name: string
|
||||
description: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
export default function Categories() {
|
||||
const categories = posts.reduce((acc, post) => {
|
||||
acc[post.category] = (acc[post.category] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
interface Post {
|
||||
category: string
|
||||
date: 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 (
|
||||
<div className="space-y-8">
|
||||
<h1 className="text-4xl font-bold text-primary">{strings.categoriesHeader}</h1>
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Object.entries(categories).map(([category, count]) => (
|
||||
<Card key={category} 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">{category}</CardTitle>
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{count} {count === 1 ? strings.categoriesPostUnitSingle : strings.categoriesPostUnitPlural }
|
||||
</Badge>
|
||||
</div>
|
||||
<CardDescription className="mt-2">
|
||||
{strings.categoriesCardDescriptionPre} {category}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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"
|
||||
{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">
|
||||
{categories.map((category) => {
|
||||
const postCount = getCategoryPostCount(category.name)
|
||||
const lastUpdated = getLastUpdatedDate(category.name)
|
||||
return (
|
||||
<Card
|
||||
key={category.id}
|
||||
className="flex flex-col justify-between border-border/40 hover:border-border/60 transition-colors"
|
||||
>
|
||||
{strings.categoriesViewPostsFromLinkText}
|
||||
</Link>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-start">
|
||||
<CardTitle className="text-xl text-primary">{category.name}</CardTitle>
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{postCount}{" "}
|
||||
{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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
120
app/category/[slug]/page.tsx
Normal file
120
app/category/[slug]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
@ -1,8 +1,9 @@
|
||||
import './globals.css'
|
||||
import "./globals.css"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { GeistSans } from 'geist/font/sans';
|
||||
import { Navbar } from "@/components/Navbar"
|
||||
import { Sidebar } from "@/components/Sidebar"
|
||||
import { GeistSans } from "geist/font/sans"
|
||||
import { Navbar } from "@/components/navigation/Navbar"
|
||||
import { SidebarProvider } from "@/context/SidebarContext"
|
||||
import ClientNav from "@/components/navigation/ClientNav"
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
@ -10,24 +11,19 @@ export default function RootLayout({
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={cn(
|
||||
"bg-background font-sans antialiased",
|
||||
GeistSans.className
|
||||
)}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<html lang="en" className={cn("bg-background font-sans antialiased", GeistSans.className)} suppressHydrationWarning>
|
||||
<body className="min-h-screen bg-background font-sans antialiased">
|
||||
<div className="relative flex min-h-screen flex-col">
|
||||
<Navbar />
|
||||
<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">
|
||||
<Sidebar />
|
||||
<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">
|
||||
{children}
|
||||
</main>
|
||||
<SidebarProvider>
|
||||
<div className="relative flex min-h-screen flex-col">
|
||||
<Navbar />
|
||||
<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">
|
||||
<ClientNav />
|
||||
<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">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
156
app/page.tsx
156
app/page.tsx
@ -1,75 +1,107 @@
|
||||
'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'
|
||||
|
||||
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",
|
||||
},
|
||||
]
|
||||
type Post = {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
category: string;
|
||||
date: number;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="space-y-8">
|
||||
<h1 className="text-4xl font-bold text-primary">{strings.latestPostsHeader}</h1>
|
||||
<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>
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{post.category}
|
||||
</Badge>
|
||||
</div>
|
||||
<CardDescription className="mt-2">{post.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{strings.recentPostsPublishedOnLabel} {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>
|
||||
{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">
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-start">
|
||||
<CardTitle className="text-xl text-primary">{post.title}</CardTitle>
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{post.category}
|
||||
</Badge>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
152
app/posts/[slug]/page.tsx
Normal file
152
app/posts/[slug]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from "react"
|
||||
import { useState, useEffect } from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
@ -19,9 +19,62 @@ export function LoginForm({
|
||||
}: React.ComponentPropsWithoutRef<"div">) {
|
||||
const [username, setUsername] = 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) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
try {
|
||||
const response = await fetch('http://localhost:3001/api/admin/login', {
|
||||
method: 'POST',
|
||||
@ -32,18 +85,31 @@ export function LoginForm({
|
||||
})
|
||||
|
||||
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) {
|
||||
console.error('Error:', error)
|
||||
console.error('[i]', error)
|
||||
setError('Failed to connect to the server. Please try again later.')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||
<div className={cn("relative flex min-h-screen flex-col", className)} {...props}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">Administration Panel</CardTitle>
|
||||
@ -52,6 +118,7 @@ export function LoginForm({
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{error && <div className="text-red-500 mb-6">{error}</div>}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="grid gap-2">
|
||||
|
29
components/navigation/ClientNav.tsx
Normal file
29
components/navigation/ClientNav.tsx
Normal 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 />
|
||||
}
|
||||
|
62
components/navigation/Navbar.tsx
Normal file
62
components/navigation/Navbar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
140
components/navigation/Sidebar.tsx
Normal file
140
components/navigation/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
44
components/navigation/sidebar/AdminSidebar.tsx
Normal file
44
components/navigation/sidebar/AdminSidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
43
components/navigation/sidebar/MobileAdminSidebar.tsx
Normal file
43
components/navigation/sidebar/MobileAdminSidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
46
components/navigation/sidebar/MobileSidebar.tsx
Normal file
46
components/navigation/sidebar/MobileSidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
30
context/SidebarContext.tsx
Normal file
30
context/SidebarContext.tsx
Normal 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
|
||||
}
|
||||
|
@ -4,6 +4,14 @@ const nextConfig: NextConfig = {
|
||||
env: {
|
||||
BLOG_NAME: process.env.BLOG_NAME,
|
||||
},
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: '**',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
21
package.json
21
package.json
@ -18,24 +18,29 @@
|
||||
"@uiw/react-md-editor": "^4.0.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"geist": "^1.3.1",
|
||||
"is-mobile": "^5.0.0",
|
||||
"lucide-react": "^0.471.1",
|
||||
"next": "15.1.4",
|
||||
"next-themes": "^0.4.4",
|
||||
"react": "^19.0.0",
|
||||
"react-device-detect": "^2.2.3",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^9.0.3",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"eslint": "^9",
|
||||
"typescript": "^5.7.3",
|
||||
"@types/node": "^20.17.13",
|
||||
"@types/react": "^19.0.7",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"postcss": "^8.5.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-next": "15.1.4",
|
||||
"@eslint/eslintrc": "^3"
|
||||
"@eslint/eslintrc": "^3.2.0"
|
||||
}
|
||||
}
|
||||
|
246
server/index.js
246
server/index.js
@ -2,6 +2,10 @@ import express from 'express';
|
||||
import sqlite3 from 'sqlite3';
|
||||
import { open } from 'sqlite';
|
||||
import cors from 'cors';
|
||||
import crypto from 'crypto';
|
||||
import figlet from 'figlet';
|
||||
import fs from 'fs';
|
||||
import http from 'http';
|
||||
|
||||
const app = express();
|
||||
const port = 3001;
|
||||
@ -26,7 +30,8 @@ let db;
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT,
|
||||
email TEXT,
|
||||
password TEXT
|
||||
password TEXT,
|
||||
key TEXT
|
||||
);
|
||||
`);
|
||||
|
||||
@ -41,37 +46,254 @@ let db;
|
||||
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());
|
||||
|
||||
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) => {
|
||||
const { username, password } = req.body;
|
||||
const key = crypto.randomBytes(32).toString('hex');
|
||||
try {
|
||||
const user = await db.get('SELECT * FROM users WHERE username = ? AND password = ?', [username, password]);
|
||||
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 {
|
||||
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) {
|
||||
console.error('Error logging in:', error);
|
||||
res.status(500).json({ success: false, message: 'Server error' });
|
||||
console.error('[!] Error logging in:', error);
|
||||
res.json({ success: false, message: 'Server error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/users/add', async (req, res) => {
|
||||
const { username, email, password } = req.body;
|
||||
const result = await db.run('INSERT INTO users (username, email, password) VALUES (?, ?, ?)', [username, email, password]);
|
||||
res.json({ id: result.lastID });
|
||||
const { username, email, password, key } = req.body;
|
||||
console.log("[i] Received request to add user", username);
|
||||
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) => {
|
||||
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]);
|
||||
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.get('/api/posts/fetchList', async (req, res) => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`Server running on http://localhost:${port}`);
|
||||
});
|
@ -20,6 +20,8 @@
|
||||
"cors": "^2.8.5",
|
||||
"ejs": "^3.1.10",
|
||||
"express": "^4.21.2",
|
||||
"figlet": "^1.8.0",
|
||||
"http": "^0.0.1-security",
|
||||
"sqlite": "^5.1.1",
|
||||
"sqlite3": "^5.1.7"
|
||||
},
|
||||
|
@ -53,5 +53,5 @@
|
||||
"createPostButtonText": "Create Post",
|
||||
"createUserButtonText": "Create User",
|
||||
|
||||
"recentPostsPublishedOnLabel": "Published on"
|
||||
"recentPostsPublishedOnLabel": "Published "
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user