diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx new file mode 100644 index 0000000..d09df91 --- /dev/null +++ b/app/admin/layout.tsx @@ -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); + + 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 ( +
+
+
+ ); + } + + if (!authorized) { + return null; + } + + return
{children}
; +} \ No newline at end of file diff --git a/app/admin/logout/page.tsx b/app/admin/logout/page.tsx new file mode 100644 index 0000000..7699344 --- /dev/null +++ b/app/admin/logout/page.tsx @@ -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; +} diff --git a/app/admin/page.tsx b/app/admin/page.tsx index f181a91..c8c108f 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -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 (

{strings.adminHeader}

@@ -46,20 +123,50 @@ export default function Admin() {
- {strings.totalPostsCardTitle} + {strings.totalUsersCardTitle} - {posts.length} + {userCtCardLoading ? ( +
+ ) : userCtCardError ? ( +
+ +

Error

+
+ ) : ( + totalUsers + )}
- + + +
+ {strings.totalPostsCardTitle} + + {postCardLoading ? ( +
+ ) : postCardError ? ( +
+ +

Error

+
+ ) : ( + totalPosts + )} +
+
+
+
+
+
+ Quick Actions + + + + +
+ {strings.totalPostsCardTitle} + + {postCardLoading ? ( +
+ ) : postCardError ? ( +
+ +

Error

+
+ ) : ( + totalPosts + )} +
+
+
+
+
+ + ) +} + diff --git a/app/admin/users/page.tsx b/app/admin/users/page.tsx index 781c94e..2995340 100644 --- a/app/admin/users/page.tsx +++ b/app/admin/users/page.tsx @@ -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 (

{strings.usersHeader}

-
- {strings.totalUsersCardTitle} - - {/* TODO: Implement user logic and counter */} - 57 - -
+ Quick Actions
+ + +
- - {strings.totalUsersLoggedInMonthCardTitle} -

{strings.totalUsersLoggedInMonthCardDescription}

-
+ {strings.totalUsersCardTitle} - {/* TODO: Implement users logged in (by month) logic + counter */} - 24 + {userCtCardLoading ? ( +
+ ) : userCtCardError ? ( +
+ +

Error

+
+ ) : ( + totalUsers + )}
diff --git a/app/categories/page.tsx b/app/categories/page.tsx index f4afcd3..d95da03 100644 --- a/app/categories/page.tsx +++ b/app/categories/page.tsx @@ -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); +interface Post { + category: string + date: number +} + +export default function Home() { + const [posts, setPosts] = useState([]) + const [categories, setCategories] = useState([]) + 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 (

{strings.categoriesHeader}

-
- {Object.entries(categories).map(([category, count]) => ( - - -
- {category} - - {count} {count === 1 ? strings.categoriesPostUnitSingle : strings.categoriesPostUnitPlural } - -
- - {strings.categoriesCardDescriptionPre} {category} - -
- -

- {strings.categoriesLastUpdatedLabel}: {posts.find(post => post.category === category)?.date} -

-
- - +
+
+ ) : error ? ( +
{error}
+ ) : ( +
+ {categories.map((category) => { + const postCount = getCategoryPostCount(category.name) + const lastUpdated = getLastUpdatedDate(category.name) + return ( + - {strings.categoriesViewPostsFromLinkText} - - - - ))} -
+ +
+ {category.name} + + {postCount}{" "} + {postCount === 1 ? strings.categoriesPostUnitSingle : strings.categoriesPostUnitPlural} + +
+ {category.description} +
+ + {lastUpdated && ( +

+ {strings.categoriesLastUpdatedLabel} {formatDate(lastUpdated)} +

+ )} +
+ + + {strings.categoriesViewPostsFromLinkText} + + + + ) + })} +
+ )}
) -} \ No newline at end of file +} + diff --git a/app/category/[slug]/page.tsx b/app/category/[slug]/page.tsx new file mode 100644 index 0000000..4cc0b02 --- /dev/null +++ b/app/category/[slug]/page.tsx @@ -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 ( +
+ {!loading &&

{category}

} + {loading ? ( +
+
+
+ ) : error ? ( +
{error}
+ ) : ( +
+ {posts.map((post) => ( + + +
+ {post.title} +
+ {post.description} +
+ +

+ {strings.recentPostsPublishedOnLabel} {formatDate(post.date)} +

+
+ + + {strings.recentPostsReadMoreFromLinkText} + + +
+ ))} +
+ )} +
+ ) +} + diff --git a/app/layout.tsx b/app/layout.tsx index eb02a91..3e1afc7 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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 ( - + -
- -
- -
- {children} -
+ +
+ +
+ +
+ {children} +
+
-
+ ) diff --git a/app/page.tsx b/app/page.tsx index 3f42d59..6431e6f 100644 --- a/app/page.tsx +++ b/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 (

{strings.latestPostsHeader}

-
- {posts.map((post) => ( - - -
- {post.title} - - {post.category} - -
- {post.description} -
- -

- {strings.recentPostsPublishedOnLabel} {post.date} -

-
- - - {strings.recentPostsReadMoreFromLinkText} - - -
- ))} -
+ {loading ? ( +
+
+
+ ) : error ? ( +
{error}
+ ) : ( +
+ {posts.map((post) => ( + + +
+ {post.title} + + {post.category} + +
+ {post.description} +
+ +

+ {strings.recentPostsPublishedOnLabel} {formatDate(post.date)} +

+
+ + + {strings.recentPostsReadMoreFromLinkText} + + +
+ ))} +
+ )}
) } diff --git a/app/posts/[slug]/page.tsx b/app/posts/[slug]/page.tsx new file mode 100644 index 0000000..f0c8e5d --- /dev/null +++ b/app/posts/[slug]/page.tsx @@ -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 ( +
+
+
+ ); + } + + if (error) { + return ( +
{error}
+ ); + } + + return ( +
+

{postTitle}

+

Published on {postDate}

+
+ + {postCategory} + +
+
+

, + h2: ({...props}) =>

, + h3: ({...props}) =>

, + h4: ({...props}) =>

, + h5: ({...props}) =>

, + em: ({...props}) => , + hr: ({...props}) =>
, + p: ({...props}) =>

, + a: ({...props}) => , + img: ({src, alt, width, height, ...props}) => {alt, + blockquote: ({...props}) =>

, + code: ({...props}) => , + pre: ({...props}) =>
,
+        table: ({...props}) => ,
+        thead: ({...props}) => ,
+        th: ({...props}) => 
, + td: ({...props}) => , + ul: ({...props}) =>
    , + ol: ({...props}) =>
      , + li: ({...props}) =>
    1. , + input: ({type, ...props}) => type === 'checkbox' ? + : + + }}> + {markdown} + + + ); +} \ No newline at end of file diff --git a/bun.lockb b/bun.lockb index b610386..329fef5 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/components/Navbar.tsx b/components/Navbar.tsx deleted file mode 100644 index 8ac9b2d..0000000 --- a/components/Navbar.tsx +++ /dev/null @@ -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 ( -
      -
      -
      - -
      - - - - - - - Menu - -
      -
      - {}}> - Home - - {}}> - Categories - - {}}> - About - -
      -
      -
      -
      -
      -
      - -
      -
      -
      -
      - ) -} - -interface MobileLinkProps extends React.ComponentPropsWithoutRef { - onOpenChange?: (open: boolean) => void - children: React.ReactNode -} - -function MobileLink({ - href, - onOpenChange, - className, - children, - ...props -}: MobileLinkProps) { - const pathname = usePathname() - return ( - { - onOpenChange?.(false) - }} - className={cn( - "text-muted-foreground transition-colors hover:text-primary", - pathname === href && "text-primary", - className - )} - {...props} - > - {children} - - ) -} - diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx deleted file mode 100644 index e1fbfcf..0000000 --- a/components/Sidebar.tsx +++ /dev/null @@ -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 ( - - ) -} - diff --git a/components/login-form.tsx b/components/login-form.tsx index a22d606..1236a97 100644 --- a/components/login-form.tsx +++ b/components/login-form.tsx @@ -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(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); + + 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 ( -
      +
      Administration Panel @@ -52,6 +118,7 @@ export function LoginForm({ + {error &&
      {error}
      }
      diff --git a/components/navigation/ClientNav.tsx b/components/navigation/ClientNav.tsx new file mode 100644 index 0000000..bd66717 --- /dev/null +++ b/components/navigation/ClientNav.tsx @@ -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 ? : + } + return isAdmin ? : +} + diff --git a/components/navigation/Navbar.tsx b/components/navigation/Navbar.tsx new file mode 100644 index 0000000..c51c2aa --- /dev/null +++ b/components/navigation/Navbar.tsx @@ -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 ( +
      +
      +
      + +
      +
      +
      + +
      +
      + +
      +
      +
      +
      + ) +} + diff --git a/components/navigation/Sidebar.tsx b/components/navigation/Sidebar.tsx new file mode 100644 index 0000000..b48f548 --- /dev/null +++ b/components/navigation/Sidebar.tsx @@ -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([]); + 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 ( + + ); +} diff --git a/components/navigation/sidebar/AdminSidebar.tsx b/components/navigation/sidebar/AdminSidebar.tsx new file mode 100644 index 0000000..279834d --- /dev/null +++ b/components/navigation/sidebar/AdminSidebar.tsx @@ -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 ( + + ) +} + diff --git a/components/navigation/sidebar/MobileAdminSidebar.tsx b/components/navigation/sidebar/MobileAdminSidebar.tsx new file mode 100644 index 0000000..5ead20e --- /dev/null +++ b/components/navigation/sidebar/MobileAdminSidebar.tsx @@ -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 ( + + ) +} + diff --git a/components/navigation/sidebar/MobileSidebar.tsx b/components/navigation/sidebar/MobileSidebar.tsx new file mode 100644 index 0000000..051583b --- /dev/null +++ b/components/navigation/sidebar/MobileSidebar.tsx @@ -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 ( + + ) +} + diff --git a/context/SidebarContext.tsx b/context/SidebarContext.tsx new file mode 100644 index 0000000..53bff14 --- /dev/null +++ b/context/SidebarContext.tsx @@ -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(undefined) + +export function SidebarProvider({ children }: { children: React.ReactNode }) { + const [isOpen, setIsOpen] = useState(false) + + const toggleSidebar = () => { + setIsOpen((prev) => !prev) + } + + return {children} +} + +export function useSidebar() { + const context = useContext(SidebarContext) + if (context === undefined) { + throw new Error("useSidebar must be used within a SidebarProvider") + } + return context +} + diff --git a/next.config.ts b/next.config.ts index af64bf1..0784fde 100644 --- a/next.config.ts +++ b/next.config.ts @@ -4,6 +4,14 @@ const nextConfig: NextConfig = { env: { BLOG_NAME: process.env.BLOG_NAME, }, + images: { + remotePatterns: [ + { + protocol: 'https', + hostname: '**', + }, + ], + }, }; export default nextConfig; diff --git a/package.json b/package.json index 28a5ed2..6936359 100644 --- a/package.json +++ b/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" } } diff --git a/server/index.js b/server/index.js index 1ed2f60..81dee04 100644 --- a/server/index.js +++ b/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}`); -}); \ No newline at end of file diff --git a/server/package.json b/server/package.json index aa947ac..2d19fde 100644 --- a/server/package.json +++ b/server/package.json @@ -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" }, diff --git a/strings.json b/strings.json index 94e7b07..ed8d650 100644 --- a/strings.json +++ b/strings.json @@ -53,5 +53,5 @@ "createPostButtonText": "Create Post", "createUserButtonText": "Create User", - "recentPostsPublishedOnLabel": "Published on" + "recentPostsPublishedOnLabel": "Published " } \ No newline at end of file