initial commit

This commit is contained in:
Ken Yasue
2025-03-25 06:19:44 +01:00
parent b97fa96c25
commit 9aef2ad891
71 changed files with 13016 additions and 1 deletions

View File

@ -0,0 +1,133 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { Geist, Geist_Mono } from "next/font/google";
import "../../globals.css";
import DatabaseInitializer from "@/lib/components/DatabaseInitializer";
import UserMenu from "@/lib/components/UserMenu";
import ThemeToggle from "@/lib/components/ThemeToggle";
import { ThemeProvider } from "@/lib/context/ThemeContext";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
interface User {
id: string;
username: string;
avatar: string | null;
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const fetchUser = async () => {
try {
const response = await fetch('/api/auth');
const data = await response.json();
if (data.authenticated) {
setUser(data.user);
}
} catch (error) {
console.error('Error fetching user:', error);
} finally {
setIsLoading(false);
}
};
fetchUser();
}, []);
if (isLoading) {
return (
<html><body>
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<div className="text-center">
<p className="text-gray-500">Loading...</p>
</div>
</div>
</body></html>
);
}
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<ThemeProvider>
<div className="min-h-screen bg-gray-100">
<nav className="bg-white shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex">
<div className="flex-shrink-0 flex items-center">
<Link href="/admin" className="text-xl font-bold text-gray-800">
KantanCMS Admin
</Link>
</div>
<div className="hidden sm:ml-6 sm:flex sm:space-x-8">
<Link
href="/admin"
className="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
>
Dashboard
</Link>
<Link
href="/admin/posts"
className="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
>
Posts
</Link>
<Link
href="/admin/users"
className="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
>
Users
</Link>
</div>
</div>
<div className="hidden sm:ml-6 sm:flex sm:items-center">
<ThemeToggle className="mr-4" />
{user && (
<UserMenu user={user} isAdmin={true} />
)}
<Link
href="/"
className="text-gray-500 hover:text-gray-700 px-3 py-2 rounded-md text-sm font-medium ml-4"
>
View Site
</Link>
</div>
</div>
</div>
</nav>
<div className="py-10">
<main>
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div className="px-4 py-8 sm:px-0">{children}</div>
</div>
</main>
</div>
</div>
</ThemeProvider>
</body></html>
);
}

View File

@ -0,0 +1,130 @@
'use client';
import { useState, FormEvent } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
export default function AdminLogin() {
const router = useRouter();
const [formData, setFormData] = useState({
username: '',
password: '',
});
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError(null);
try {
const response = await fetch('/api/auth', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(formData),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Login failed');
}
// Redirect to admin dashboard on success
router.push('/admin');
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
setIsLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
KantanCMS Admin
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Sign in to your account
</p>
</div>
{error && (
<div className="bg-red-50 border-l-4 border-red-400 p-4">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<p className="text-sm text-red-700">{error}</p>
</div>
</div>
</div>
)}
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="rounded-md shadow-sm -space-y-px">
<div>
<label htmlFor="username" className="sr-only">
Username
</label>
<input
id="username"
name="username"
type="text"
autoComplete="username"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
placeholder="Username"
value={formData.username}
onChange={handleChange}
/>
</div>
<div>
<label htmlFor="password" className="sr-only">
Password
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
placeholder="Password"
value={formData.password}
onChange={handleChange}
/>
</div>
</div>
<div>
<button
type="submit"
disabled={isLoading}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
>
{isLoading ? 'Signing in...' : 'Sign in'}
</button>
</div>
</form>
<div className="text-center">
<Link href="/" className="text-sm text-indigo-600 hover:text-indigo-500">
Return to site
</Link>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,37 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
export default function Logout() {
const router = useRouter();
useEffect(() => {
const performLogout = async () => {
try {
await fetch('/api/auth', {
method: 'DELETE',
});
// Redirect to login page
router.push('/admin/login');
router.refresh();
} catch (error) {
console.error('Logout error:', error);
// Redirect to login page even if there's an error
router.push('/admin/login');
}
};
performLogout();
}, [router]);
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<h2 className="text-xl font-semibold mb-2">Logging out...</h2>
<p className="text-gray-500">Please wait while we log you out.</p>
</div>
</div>
);
}

View File

@ -0,0 +1,6 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'KantanCMS Admin',
description: 'Admin dashboard for KantanCMS',
};

View File

@ -0,0 +1,82 @@
import Link from 'next/link';
export default async function AdminDashboard() {
return (
<div>
<h1 className="text-2xl font-semibold text-gray-900">Dashboard</h1>
<div className="mt-6 grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{/* Posts card */}
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<div className="flex items-center">
<div className="flex-shrink-0 bg-indigo-500 rounded-md p-3">
<svg className="h-6 w-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" />
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Posts</dt>
<dd>
<div className="text-lg font-medium text-gray-900">--</div>
</dd>
</dl>
</div>
</div>
</div>
<div className="bg-gray-50 px-4 py-4 sm:px-6">
<div className="text-sm">
<Link href="/admin/posts" className="font-medium text-indigo-600 hover:text-indigo-500">
View all posts
</Link>
</div>
</div>
</div>
{/* Users card */}
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<div className="flex items-center">
<div className="flex-shrink-0 bg-green-500 rounded-md p-3">
<svg className="h-6 w-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Users</dt>
<dd>
<div className="text-lg font-medium text-gray-900">--</div>
</dd>
</dl>
</div>
</div>
</div>
<div className="bg-gray-50 px-4 py-4 sm:px-6">
<div className="text-sm">
<Link href="/admin/users" className="font-medium text-indigo-600 hover:text-indigo-500">
View all users
</Link>
</div>
</div>
</div>
{/* Quick actions card */}
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg font-medium leading-6 text-gray-900">Quick Actions</h3>
<div className="mt-4 space-y-2">
<Link href="/admin/posts/new" className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Create New Post
</Link>
<Link href="/admin/users/new" className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 ml-3">
Add New User
</Link>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,50 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
interface DeleteButtonProps {
postId: string;
}
export default function DeleteButton({ postId }: DeleteButtonProps) {
const router = useRouter();
const [isDeleting, setIsDeleting] = useState(false);
const handleDelete = async () => {
if (!confirm('Are you sure you want to delete this post? This action cannot be undone.')) {
return;
}
setIsDeleting(true);
try {
const response = await fetch(`/api/posts/${postId}`, {
method: 'DELETE',
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to delete post');
}
// Refresh the page to show updated list
router.refresh();
} catch (error) {
console.error('Error deleting post:', error);
alert('Failed to delete post. Please try again.');
} finally {
setIsDeleting(false);
}
};
return (
<button
className="text-red-600 hover:text-red-900"
onClick={handleDelete}
disabled={isDeleting}
>
{isDeleting ? 'Deleting...' : 'Delete'}
</button>
);
}

View File

@ -0,0 +1,232 @@
'use client';
import { useState, useEffect } from 'react';
import Editor from '@/lib/components/EditorJS';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
interface Post {
id: string;
title: string;
content: string;
parentId: string | null;
userId: string;
}
interface User {
id: string;
username: string;
}
interface EditPostProps {
params: {
id: string;
};
}
export default function EditPost({ params }: EditPostProps) {
const router = useRouter();
const [title, setTitle] = useState('');
const [content, setContent] = useState<any>({});
const [parentId, setParentId] = useState<string | null>(null);
const [userId, setUserId] = useState<string>('');
const [posts, setPosts] = useState<Post[]>([]);
const [users, setUsers] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Fetch post data and options for dropdowns
useEffect(() => {
const fetchData = async () => {
try {
setIsLoading(true);
// Fetch the post to edit
const postResponse = await fetch(`/api/posts/${params.id}`);
if (!postResponse.ok) {
throw new Error('Failed to fetch post');
}
const postData = await postResponse.json();
// Set form values
setTitle(postData.title);
// Try to parse the content as JSON, or use it as a simple string if parsing fails
try {
setContent(JSON.parse(postData.content));
} catch (e) {
// If content is not valid JSON, create a simple paragraph block
setContent({
time: new Date().getTime(),
blocks: [
{
type: 'paragraph',
data: {
text: postData.content
}
}
]
});
}
setParentId(postData.parentId);
setUserId(postData.userId);
// Fetch all posts for parent selection (excluding the current post)
const postsResponse = await fetch('/api/posts');
const postsData = await postsResponse.json();
setPosts(postsData.filter((post: Post) => post.id !== params.id));
// Fetch users for author selection
const usersResponse = await fetch('/api/users');
const usersData = await usersResponse.json();
setUsers(usersData);
} catch (err) {
console.error('Error fetching data:', err);
setError('Failed to load data. Please try again.');
} finally {
setIsLoading(false);
}
};
fetchData();
}, [params.id]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!title || !content) {
setError('Please fill in all required fields');
return;
}
setIsSubmitting(true);
setError(null);
try {
const response = await fetch(`/api/posts/${params.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
title,
content: JSON.stringify(content),
parentId: parentId || null,
}),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to update post');
}
router.push('/admin/posts');
router.refresh();
} catch (err) {
console.error('Error updating post:', err);
setError(err instanceof Error ? err.message : 'An unknown error occurred');
} finally {
setIsSubmitting(false);
}
};
if (isLoading) {
return (
<div className="flex justify-center items-center h-64">
<div className="text-gray-500">Loading post data...</div>
</div>
);
}
return (
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-semibold text-gray-900">Edit Post</h1>
<Link
href="/admin/posts"
className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
Cancel
</Link>
</div>
{error && (
<div className="mb-4 p-4 text-red-700 bg-red-100 rounded-md">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="title" className="block text-sm font-medium text-gray-700">
Title <span className="text-red-500">*</span>
</label>
<input
type="text"
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm text-black px-4 py-2"
required
/>
</div>
<div>
<label htmlFor="content" className="block text-sm font-medium text-gray-700 mb-2">
Content <span className="text-red-500">*</span>
</label>
<Editor
data={content}
onChange={setContent}
/>
</div>
<div>
<label htmlFor="parent" className="block text-sm font-medium text-gray-700">
Parent Post
</label>
<select
id="parent"
value={parentId || ''}
onChange={(e) => setParentId(e.target.value || null)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm text-black px-4 py-2"
>
<option value="">None (Root Post)</option>
{posts.map((post) => (
<option key={post.id} value={post.id}>
{post.title}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
Author
</label>
<div className="mt-1 block w-full rounded-md border border-gray-300 bg-gray-50 px-4 py-2 text-gray-500">
{users.find(user => user.id === userId)?.username || 'Unknown'}
</div>
<p className="mt-1 text-xs text-gray-500">Author cannot be changed after creation</p>
</div>
<div className="flex justify-end space-x-3">
<Link
href="/admin/posts"
className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
Cancel
</Link>
<button
type="submit"
disabled={isSubmitting}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:bg-indigo-300"
>
{isSubmitting ? 'Saving...' : 'Save Changes'}
</button>
</div>
</form>
</div>
);
}

View File

@ -0,0 +1,187 @@
'use client';
import { useState, useEffect } from 'react';
import Editor from '@/lib/components/EditorJS';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
interface Post {
id: string;
title: string;
parentId: string | null;
}
interface User {
id: string;
username: string;
}
export default function NewPost() {
const router = useRouter();
const [title, setTitle] = useState('');
const [content, setContent] = useState<any>({});
const [parentId, setParentId] = useState<string | null>(null);
const [posts, setPosts] = useState<Post[]>([]);
const [currentUser, setCurrentUser] = useState<User | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Fetch posts and current user
useEffect(() => {
const fetchData = async () => {
try {
// Fetch posts for parent selection
const postsResponse = await fetch('/api/posts');
const postsData = await postsResponse.json();
setPosts(postsData);
// Fetch current user
const authResponse = await fetch('/api/auth');
const authData = await authResponse.json();
if (authData.authenticated) {
setCurrentUser(authData.user);
} else {
setError('You must be logged in to create a post');
}
} catch (err) {
console.error('Error fetching data:', err);
setError('Failed to load data. Please try again.');
}
};
fetchData();
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!title || !content) {
setError('Please fill in all required fields');
return;
}
if (!currentUser) {
setError('You must be logged in to create a post');
return;
}
setIsSubmitting(true);
setError(null);
try {
const response = await fetch('/api/posts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
title,
content: JSON.stringify(content),
userId: currentUser.id,
parentId: parentId || null,
}),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to create post');
}
router.push('/admin/posts');
router.refresh();
} catch (err) {
console.error('Error creating post:', err);
setError(err instanceof Error ? err.message : 'An unknown error occurred');
} finally {
setIsSubmitting(false);
}
};
return (
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-semibold text-gray-900">Create New Post</h1>
<Link
href="/admin/posts"
className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
Cancel
</Link>
</div>
{error && (
<div className="mb-4 p-4 text-red-700 bg-red-100 rounded-md">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="title" className="block text-sm font-medium text-gray-700">
Title <span className="text-red-500">*</span>
</label>
<input
type="text"
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm text-black px-4 py-2"
required
/>
</div>
<div>
<label htmlFor="content" className="block text-sm font-medium text-gray-700 mb-2">
Content <span className="text-red-500">*</span>
</label>
<Editor
data={content}
onChange={setContent}
/>
</div>
<div>
<label htmlFor="parent" className="block text-sm font-medium text-gray-700">
Parent Post
</label>
<select
id="parent"
value={parentId || ''}
onChange={(e) => setParentId(e.target.value || null)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm text-black px-4 py-2"
>
<option value="">None (Root Post)</option>
{posts.map((post) => (
<option key={post.id} value={post.id}>
{post.title}
</option>
))}
</select>
</div>
{currentUser && (
<div>
<label className="block text-sm font-medium text-gray-700">
Author
</label>
<div className="mt-1 block w-full rounded-md border border-gray-300 bg-gray-50 px-4 py-2 text-gray-500">
{currentUser.username}
</div>
<p className="mt-1 text-xs text-gray-500">Posts are created with your current user account</p>
</div>
)}
<div className="flex justify-end">
<button
type="submit"
disabled={isSubmitting}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:bg-indigo-300"
>
{isSubmitting ? 'Creating...' : 'Create Post'}
</button>
</div>
</form>
</div>
);
}

View File

@ -0,0 +1,104 @@
import Link from 'next/link';
import { Post, getDataSource } from '@/lib/database';
import DeleteButton from './DeleteButton';
export default async function AdminPosts() {
// Fetch posts from the database
const dataSource = await getDataSource();
const postRepository = dataSource.getRepository(Post);
const posts = await postRepository.find({
relations: ['user'],
order: { createdAt: 'DESC' }
});
return (
<div>
<div className="flex justify-between items-center">
<h1 className="text-2xl font-semibold text-gray-900">Posts</h1>
<Link
href="/admin/posts/new"
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Add New Post
</Link>
</div>
<div className="mt-8 flex flex-col">
<div className="-my-2 -mx-4 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div className="inline-block min-w-full py-2 align-middle md:px-6 lg:px-8">
<div className="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
<table className="min-w-full divide-y divide-gray-300">
<thead className="bg-gray-50">
<tr>
<th
scope="col"
className="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6"
>
Title
</th>
<th
scope="col"
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
>
Author
</th>
<th
scope="col"
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
>
Created
</th>
<th
scope="col"
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
>
Modified
</th>
<th scope="col" className="relative py-3.5 pl-3 pr-4 sm:pr-6">
<span className="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 bg-white">
{posts.length > 0 ? (
posts.map((post: Post) => (
<tr key={post.id}>
<td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6">
{post.title}
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{post.user?.username || 'Unknown'}
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{new Date(post.createdAt).toLocaleDateString()}
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{new Date(post.modifiedAt).toLocaleDateString()}
</td>
<td className="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
<Link
href={`/admin/posts/edit/${post.id}`}
className="text-indigo-600 hover:text-indigo-900 mr-4"
>
Edit
</Link>
<DeleteButton postId={post.id} />
</td>
</tr>
))
) : (
<tr>
<td colSpan={5} className="py-4 pl-4 pr-3 text-sm text-gray-500 text-center">
No posts found. Create your first post!
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,11 @@
'use client';
import ProfileEditor from '@/lib/components/ProfileEditor';
export default function AdminProfilePage() {
return (
<div className="max-w-2xl mx-auto">
<ProfileEditor isAdmin={true} />
</div>
);
}

View File

@ -0,0 +1,57 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
interface DeleteButtonProps {
userId: string;
username: string;
}
export default function DeleteButton({ userId, username }: DeleteButtonProps) {
const [isDeleting, setIsDeleting] = useState(false);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
const handleDelete = async () => {
if (!confirm(`Are you sure you want to delete user "${username}"?`)) {
return;
}
setIsDeleting(true);
setError(null);
try {
const response = await fetch(`/api/users/${userId}`, {
method: 'DELETE',
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to delete user');
}
// Refresh the page to show updated user list
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setIsDeleting(false);
}
};
return (
<>
<button
className="text-red-600 hover:text-red-900 disabled:opacity-50"
onClick={handleDelete}
disabled={isDeleting}
>
{isDeleting ? 'Deleting...' : 'Delete'}
</button>
{error && (
<div className="text-red-500 text-xs mt-1">{error}</div>
)}
</>
);
}

View File

@ -0,0 +1,234 @@
'use client';
import { useState, useEffect, FormEvent, ChangeEvent } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import Image from 'next/image';
interface User {
id: string;
username: string;
avatar: string | null;
createdAt: string;
modifiedAt: string;
}
interface EditUserProps {
id: string;
}
export default function EditUser({ id }: EditUserProps) {
const router = useRouter();
const [formData, setFormData] = useState({
username: '',
password: '', // Optional for updates
});
const [avatar, setAvatar] = useState<File | null>(null);
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Fetch user data
useEffect(() => {
const fetchUser = async () => {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error('Failed to fetch user');
}
const user: User = await response.json();
setFormData({
username: user.username,
password: '', // Don't populate password
});
if (user.avatar) {
setAvatarPreview(user.avatar);
}
setIsLoading(false);
} catch (err) {
setError('Failed to load user data');
setIsLoading(false);
}
};
fetchUser();
}, [id]);
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
const handleAvatarChange = (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
const file = e.target.files[0];
setAvatar(file);
// Create a preview URL
const reader = new FileReader();
reader.onloadend = () => {
setAvatarPreview(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
setError(null);
try {
// Validate form
if (!formData.username) {
throw new Error('Username is required');
}
// Create form data for submission
const submitData = new FormData();
submitData.append('username', formData.username);
// Only include password if it was changed
if (formData.password) {
submitData.append('password', formData.password);
}
// Only include avatar if a new one was selected
if (avatar) {
submitData.append('avatar', avatar);
}
// Submit the form
const response = await fetch(`/api/users/${id}`, {
method: 'PUT',
body: submitData,
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to update user');
}
// Redirect to users list on success
router.push('/admin/users');
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
setIsSubmitting(false);
}
};
if (isLoading) {
return <div className="text-center py-10">Loading...</div>;
}
return (
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-semibold text-gray-900">Edit User</h1>
<Link
href="/admin/users"
className="text-indigo-600 hover:text-indigo-900"
>
Back to Users
</Link>
</div>
{error && (
<div className="bg-red-50 border-l-4 border-red-400 p-4 mb-6">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<p className="text-sm text-red-700">{error}</p>
</div>
</div>
</div>
)}
<form onSubmit={handleSubmit} className="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4">
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="username">
Username
</label>
<input
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
id="username"
type="text"
name="username"
value={formData.username}
onChange={handleChange}
placeholder="Username"
required
/>
</div>
<div className="mb-6">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="password">
Password (leave blank to keep current password)
</label>
<input
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
id="password"
type="password"
name="password"
value={formData.password}
onChange={handleChange}
placeholder="New password (optional)"
/>
</div>
<div className="mb-6">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="avatar">
Avatar
</label>
<input
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
id="avatar"
type="file"
name="avatar"
onChange={handleAvatarChange}
accept="image/*"
/>
{avatarPreview && (
<div className="mt-2">
<Image
src={avatarPreview}
alt="Avatar preview"
width={80}
height={80}
className="rounded-full object-cover"
/>
</div>
)}
</div>
<div className="flex items-center justify-between">
<button
className="bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline disabled:opacity-50"
type="submit"
disabled={isSubmitting}
>
{isSubmitting ? 'Updating...' : 'Update User'}
</button>
<Link
href="/admin/users"
className="inline-block align-baseline font-bold text-sm text-indigo-600 hover:text-indigo-800"
>
Cancel
</Link>
</div>
</form>
</div>
);
}

View File

@ -0,0 +1,6 @@
import EditUser from '../../components/EditUser';
export default async function EditUserPage(props: { params: Promise<{ id: string }> }) {
const { id } = await props.params;
return <EditUser id={id} />;
}

View File

@ -0,0 +1,182 @@
'use client';
import { useState, FormEvent, ChangeEvent } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import Image from 'next/image';
export default function NewUser() {
const router = useRouter();
const [formData, setFormData] = useState({
username: '',
password: '',
});
const [avatar, setAvatar] = useState<File | null>(null);
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
const handleAvatarChange = (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
const file = e.target.files[0];
setAvatar(file);
// Create a preview URL
const reader = new FileReader();
reader.onloadend = () => {
setAvatarPreview(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
setError(null);
try {
// Validate form
if (!formData.username || !formData.password) {
throw new Error('Username and password are required');
}
// Create form data for submission
const submitData = new FormData();
submitData.append('username', formData.username);
submitData.append('password', formData.password);
if (avatar) {
submitData.append('avatar', avatar);
}
// Submit the form
const response = await fetch('/api/users', {
method: 'POST',
body: submitData,
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to create user');
}
// Redirect to users list on success
router.push('/admin/users');
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
setIsSubmitting(false);
}
};
return (
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-semibold text-gray-900">Add New User</h1>
<Link
href="/admin/users"
className="text-indigo-600 hover:text-indigo-900"
>
Back to Users
</Link>
</div>
{error && (
<div className="bg-red-50 border-l-4 border-red-400 p-4 mb-6">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<p className="text-sm text-red-700">{error}</p>
</div>
</div>
</div>
)}
<form onSubmit={handleSubmit} className="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4">
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="username">
Username
</label>
<input
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
id="username"
type="text"
name="username"
value={formData.username}
onChange={handleChange}
placeholder="Username"
required
/>
</div>
<div className="mb-6">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="password">
Password
</label>
<input
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
id="password"
type="password"
name="password"
value={formData.password}
onChange={handleChange}
placeholder="Password"
required
/>
</div>
<div className="mb-6">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="avatar">
Avatar
</label>
<input
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
id="avatar"
type="file"
name="avatar"
onChange={handleAvatarChange}
accept="image/*"
/>
{avatarPreview && (
<div className="mt-2">
<Image
src={avatarPreview}
alt="Avatar preview"
width={80}
height={80}
className="rounded-full object-cover"
/>
</div>
)}
</div>
<div className="flex items-center justify-between">
<button
className="bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline disabled:opacity-50"
type="submit"
disabled={isSubmitting}
>
{isSubmitting ? 'Creating...' : 'Create User'}
</button>
<Link
href="/admin/users"
className="inline-block align-baseline font-bold text-sm text-indigo-600 hover:text-indigo-800"
>
Cancel
</Link>
</div>
</form>
</div>
);
}

View File

@ -0,0 +1,118 @@
import Link from 'next/link';
import Image from 'next/image';
import { getDataSource, User } from '@/lib/database';
import DeleteButton from './DeleteButton';
export default async function AdminUsers() {
// Fetch users from the database
const dataSource = await getDataSource();
const userRepository = dataSource.getRepository(User);
const users = await userRepository.find({
select: ['id', 'username', 'avatar', 'createdAt', 'modifiedAt'],
order: { createdAt: 'DESC' }
});
return (
<div>
<div className="flex justify-between items-center">
<h1 className="text-2xl font-semibold text-gray-900">Users</h1>
<Link
href="/admin/users/new"
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Add New User
</Link>
</div>
<div className="mt-8 flex flex-col">
<div className="-my-2 -mx-4 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div className="inline-block min-w-full py-2 align-middle md:px-6 lg:px-8">
<div className="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
<table className="min-w-full divide-y divide-gray-300">
<thead className="bg-gray-50">
<tr>
<th
scope="col"
className="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6"
>
Username
</th>
<th
scope="col"
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
>
Avatar
</th>
<th
scope="col"
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
>
Created
</th>
<th
scope="col"
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
>
Modified
</th>
<th scope="col" className="relative py-3.5 pl-3 pr-4 sm:pr-6">
<span className="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 bg-white">
{users.length > 0 ? (
users.map((user) => (
<tr key={user.id}>
<td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6">
{user.username}
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{user.avatar ? (
<Image
src={user.avatar}
alt={user.username}
width={40}
height={40}
className="rounded-full"
/>
) : (
<div className="h-10 w-10 rounded-full bg-gray-200 flex items-center justify-center text-gray-500">
{user.username.charAt(0).toUpperCase()}
</div>
)}
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{new Date(user.createdAt).toLocaleDateString()}
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{new Date(user.modifiedAt).toLocaleDateString()}
</td>
<td className="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
<Link
href={`/admin/users/edit/${user.id}`}
className="text-indigo-600 hover:text-indigo-900 mr-4"
>
Edit
</Link>
<DeleteButton userId={user.id} username={user.username} />
</td>
</tr>
))
) : (
<tr>
<td colSpan={5} className="py-4 pl-4 pr-3 text-sm text-gray-500 text-center">
No users found. Create your first user!
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,42 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "../globals.css";
import DatabaseInitializer from "@/lib/components/DatabaseInitializer";
import { ThemeProvider } from "@/lib/context/ThemeContext";
import ThemeToggleWrapper from "@/lib/components/ThemeToggleWrapper";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "KantanCMS",
description: "A simple CMS with admin console and frontpage",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{/* Initialize database connection */}
<DatabaseInitializer />
<ThemeProvider>
<ThemeToggleWrapper />
{children}
</ThemeProvider>
</body>
</html>
);
}

102
src/app/(front)/page.tsx Normal file
View File

@ -0,0 +1,102 @@
import Link from 'next/link';
import { Post, getDataSource } from '@/lib/database';
import FrontendHeader from '@/lib/components/FrontendHeader';
import PostSidebar from '@/lib/components/PostSidebar';
import EditorJSRenderer from '@/lib/components/EditorJSRenderer';
export default async function Home() {
// Fetch posts from the database
const dataSource = await getDataSource();
const postRepository = dataSource.getRepository(Post);
const posts = await postRepository.find({
relations: ['user'],
order: { createdAt: 'DESC' },
take: 10 // Limit to 10 most recent posts
});
return (
<div className="min-h-screen bg-gray-50">
<FrontendHeader />
<main>
<div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div className="px-4 py-6 sm:px-0">
<div className="flex flex-col md:flex-row gap-6">
{/* Sidebar */}
<div className="md:w-1/4">
<PostSidebar />
</div>
{/* Main content */}
<div className="md:w-3/4 border-4 border-dashed border-gray-200 rounded-lg p-4 min-h-96">
<h2 className="text-2xl font-bold text-black mb-6">Latest Posts</h2>
{posts.length > 0 ? (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{posts.map((post) => (
<div key={post.id} className="bg-white overflow-hidden shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg font-medium text-gray-900 truncate">
{post.title}
</h3>
<div className="mt-2 text-sm text-gray-500 line-clamp-3 overflow-hidden max-h-16">
{/* Try to extract a preview from the EditorJS content */}
{(() => {
try {
const content = JSON.parse(post.content);
if (content.blocks && content.blocks.length > 0) {
// Get the first block's text content
const firstBlock = content.blocks[0];
if (firstBlock.type === 'paragraph' && firstBlock.data.text) {
return firstBlock.data.text.substring(0, 150) + (firstBlock.data.text.length > 150 ? '...' : '');
}
}
} catch (e) {
// If parsing fails, just show the first 150 characters
}
return post.content.substring(0, 150) + (post.content.length > 150 ? '...' : '');
})()}
</div>
<div className="mt-3">
<Link
href={`/posts/${post.id}`}
className="text-indigo-600 hover:text-indigo-900 text-sm font-medium"
>
Read more
</Link>
</div>
</div>
<div className="bg-gray-50 px-4 py-4 sm:px-6">
<div className="text-sm text-gray-500">
Posted on {new Date(post.createdAt).toLocaleDateString()}
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-12">
<p className="text-gray-500 mb-4">No posts found.</p>
<p className="text-gray-500">
Visit the{' '}
<Link href="/admin" className="text-indigo-600 hover:text-indigo-900">
admin panel
</Link>{' '}
to create your first post.
</p>
</div>
)}
</div>
</div>
</div>
</div>
</main>
<footer className="bg-white">
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
<p className="text-center text-gray-500 text-sm">
&copy; {new Date().getFullYear()} KantanCMS. All rights reserved.
</p>
</div>
</footer>
</div>
);
}

View File

@ -0,0 +1,108 @@
import Link from 'next/link';
import { Post, getDataSource } from '@/lib/database';
import FrontendHeader from '@/lib/components/FrontendHeader';
import PostSidebar from '@/lib/components/PostSidebar';
import EditorJSRenderer from '@/lib/components/EditorJSRenderer';
interface PostDetailProps {
params: {
id: string;
};
}
// Generate static params for all posts
export async function generateStaticParams() {
const dataSource = await getDataSource();
const postRepository = dataSource.getRepository(Post);
const posts = await postRepository.find();
return posts.map((post) => ({
id: post.id,
}));
}
export default async function PostDetail({ params }: PostDetailProps) {
// Fetch the post from the database
const dataSource = await getDataSource();
const postRepository = dataSource.getRepository(Post);
const post = await postRepository.findOne({
where: { id: params.id },
relations: ['user', 'parent']
});
if (!post) {
return (
<div className="min-h-screen bg-gray-50 flex flex-col">
<FrontendHeader />
<main className="flex-grow">
<div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div className="px-4 py-6 sm:px-0">
<div className="text-center py-12">
<h2 className="text-2xl font-bold text-gray-900 mb-4">Post Not Found</h2>
<p className="text-gray-500 mb-4">The post you are looking for does not exist.</p>
<Link href="/" className="text-indigo-600 hover:text-indigo-900">
Return to homepage
</Link>
</div>
</div>
</div>
</main>
<footer className="bg-white">
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
<p className="text-center text-gray-500 text-sm">
&copy; {new Date().getFullYear()} KantanCMS. All rights reserved.
</p>
</div>
</footer>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 flex flex-col">
<FrontendHeader />
<main className="flex-grow">
<div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div className="px-4 py-6 sm:px-0">
<div className="flex flex-col md:flex-row gap-6">
{/* Sidebar */}
<div className="md:w-1/4">
<PostSidebar />
</div>
{/* Main content */}
<div className="md:w-3/4">
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
<div className="px-4 py-5 sm:px-6">
<h2 className="text-2xl font-bold text-gray-900">{post.title}</h2>
<p className="mt-1 max-w-2xl text-sm text-gray-500">
Posted on {new Date(post.createdAt).toLocaleDateString()}
{post.user && ` by ${post.user.username}`}
</p>
</div>
<div className="border-t border-gray-200">
<div className="px-4 py-5 sm:p-6">
<EditorJSRenderer data={post.content} />
</div>
</div>
<div className="border-t border-gray-200 px-4 py-4 sm:px-6">
<Link href="/" className="text-indigo-600 hover:text-indigo-900">
Back to all posts
</Link>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<footer className="bg-white mt-auto">
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
<p className="text-center text-gray-500 text-sm">
&copy; {new Date().getFullYear()} KantanCMS. All rights reserved.
</p>
</div>
</footer>
</div>
);
}

View File

@ -0,0 +1,38 @@
'use client';
import ProfileEditor from '@/lib/components/ProfileEditor';
import Link from 'next/link';
export default function FrontendProfilePage() {
return (
<div className="min-h-screen bg-gray-50">
<header className="bg-white shadow">
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8 flex justify-between items-center">
<h1 className="text-3xl font-bold text-gray-900">KantanCMS</h1>
<Link
href="/"
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700"
>
Back to Home
</Link>
</div>
</header>
<main>
<div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div className="px-4 py-6 sm:px-0">
<div className="max-w-2xl mx-auto">
<ProfileEditor isAdmin={false} />
</div>
</div>
</div>
</main>
<footer className="bg-white">
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
<p className="text-center text-gray-500 text-sm">
&copy; {new Date().getFullYear()} KantanCMS. All rights reserved.
</p>
</div>
</footer>
</div>
);
}

124
src/app/api/auth/route.ts Normal file
View File

@ -0,0 +1,124 @@
import { NextRequest, NextResponse } from 'next/server';
import { getDataSource, User } from '@/lib/database';
// POST /api/auth - Login
export async function POST(request: NextRequest) {
try {
const data = await request.json();
const { username, password } = data;
// Validate required fields
if (!username || !password) {
return NextResponse.json(
{ error: 'Username and password are required' },
{ status: 400 }
);
}
// Get user from database
const dataSource = await getDataSource();
const userRepository = dataSource.getRepository(User);
const user = await userRepository.findOne({ where: { username } });
// Check if user exists
if (!user) {
return NextResponse.json(
{ error: 'Invalid username or password' },
{ status: 401 }
);
}
// Validate password
const isPasswordValid = await user.validatePassword(password);
if (!isPasswordValid) {
return NextResponse.json(
{ error: 'Invalid username or password' },
{ status: 401 }
);
}
// Return success without password
const { password: _, ...userWithoutPassword } = user;
// Create response with authentication cookie
const response = NextResponse.json(userWithoutPassword);
response.cookies.set({
name: 'auth',
value: user.id,
httpOnly: true,
path: '/',
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 7, // 1 week
sameSite: 'strict',
});
return response;
} catch (error) {
console.error('Login error:', error);
return NextResponse.json(
{ error: 'Authentication failed' },
{ status: 500 }
);
}
}
// DELETE /api/auth - Logout
export async function DELETE() {
try {
// Create response and clear authentication cookie
const response = NextResponse.json({ success: true });
response.cookies.delete('auth');
return response;
} catch (error) {
console.error('Logout error:', error);
return NextResponse.json(
{ error: 'Logout failed' },
{ status: 500 }
);
}
}
// GET /api/auth - Check if user is authenticated
export async function GET(request: NextRequest) {
try {
// Get auth cookie from request
const authCookie = request.cookies.get('auth');
if (!authCookie?.value) {
return NextResponse.json(
{ authenticated: false },
{ status: 401 }
);
}
// Get user from database
const dataSource = await getDataSource();
const userRepository = dataSource.getRepository(User);
const user = await userRepository.findOne({
where: { id: authCookie.value },
select: ['id', 'username', 'avatar', 'theme', 'createdAt', 'modifiedAt']
});
if (!user) {
// Create response and clear invalid auth cookie
const response = NextResponse.json(
{ authenticated: false },
{ status: 401 }
);
response.cookies.delete('auth');
return response;
}
return NextResponse.json({
authenticated: true,
user
});
} catch (error) {
console.error('Auth check error:', error);
return NextResponse.json(
{ authenticated: false },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,113 @@
import { NextRequest, NextResponse } from 'next/server';
import { getDataSource, Post } from '@/lib/database';
// GET /api/posts/[id] - Get a specific post
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const id = params.id;
const dataSource = await getDataSource();
const postRepository = dataSource.getRepository(Post);
const post = await postRepository.findOne({
where: { id },
relations: ['user', 'parent']
});
if (!post) {
return NextResponse.json(
{ error: 'Post not found' },
{ status: 404 }
);
}
return NextResponse.json(post);
} catch (error) {
console.error('Error fetching post:', error);
return NextResponse.json(
{ error: 'Failed to fetch post' },
{ status: 500 }
);
}
}
// PUT /api/posts/[id] - Update a post
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const id = params.id;
const data = await request.json();
const { title, content, parentId } = data;
// Validate required fields
if (!title || !content) {
return NextResponse.json(
{ error: 'Title and content are required' },
{ status: 400 }
);
}
const dataSource = await getDataSource();
const postRepository = dataSource.getRepository(Post);
// Check if post exists
const post = await postRepository.findOneBy({ id });
if (!post) {
return NextResponse.json(
{ error: 'Post not found' },
{ status: 404 }
);
}
// Update post
post.title = title;
post.content = content;
post.parentId = parentId || null;
await postRepository.save(post);
return NextResponse.json(post);
} catch (error) {
console.error('Error updating post:', error);
return NextResponse.json(
{ error: 'Failed to update post' },
{ status: 500 }
);
}
}
// DELETE /api/posts/[id] - Delete a post
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const id = params.id;
const dataSource = await getDataSource();
const postRepository = dataSource.getRepository(Post);
// Check if post exists
const post = await postRepository.findOneBy({ id });
if (!post) {
return NextResponse.json(
{ error: 'Post not found' },
{ status: 404 }
);
}
// Delete post
await postRepository.remove(post);
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error deleting post:', error);
return NextResponse.json(
{ error: 'Failed to delete post' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,78 @@
import { NextRequest, NextResponse } from 'next/server';
import { getDataSource, Post } from '@/lib/database';
// GET /api/posts - Get all posts
export async function GET(request: NextRequest) {
try {
const dataSource = await getDataSource();
const postRepository = dataSource.getRepository(Post);
// Get query parameters
const url = new URL(request.url);
const parentId = url.searchParams.get('parentId');
// Build query
let query = postRepository.createQueryBuilder('post')
.leftJoinAndSelect('post.user', 'user')
.leftJoinAndSelect('post.parent', 'parent')
.orderBy('post.createdAt', 'DESC');
// Filter by parentId if provided
if (parentId) {
if (parentId === 'null') {
// Get root posts (no parent)
query = query.where('post.parentId IS NULL');
} else {
// Get children of specific parent
query = query.where('post.parentId = :parentId', { parentId });
}
}
const posts = await query.getMany();
return NextResponse.json(posts);
} catch (error) {
console.error('Error fetching posts:', error);
return NextResponse.json(
{ error: 'Failed to fetch posts' },
{ status: 500 }
);
}
}
// POST /api/posts - Create a new post
export async function POST(request: NextRequest) {
try {
const data = await request.json();
const { title, content, userId, parentId } = data;
// Validate required fields
if (!title || !content || !userId) {
return NextResponse.json(
{ error: 'Title, content, and userId are required' },
{ status: 400 }
);
}
const dataSource = await getDataSource();
const postRepository = dataSource.getRepository(Post);
// Create new post
const newPost = postRepository.create({
title,
content,
userId,
parentId: parentId || null
});
await postRepository.save(newPost);
return NextResponse.json(newPost, { status: 201 });
} catch (error) {
console.error('Error creating post:', error);
return NextResponse.json(
{ error: 'Failed to create post' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,76 @@
'use server';
import { NextRequest, NextResponse } from 'next/server';
import { writeFile } from 'fs/promises';
import path from 'path';
import { existsSync, mkdirSync } from 'fs';
// Ensure uploads directory exists
const ensureUploadsDir = () => {
const uploadsDir = path.join(process.cwd(), 'public/uploads');
if (!existsSync(uploadsDir)) {
mkdirSync(uploadsDir, { recursive: true });
}
return uploadsDir;
};
export async function POST(request: NextRequest) {
try {
const formData = await request.formData();
const file = formData.get('image') as File;
if (!file) {
return NextResponse.json(
{ error: 'No file uploaded' },
{ status: 400 }
);
}
// Validate file type
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (!validTypes.includes(file.type)) {
return NextResponse.json(
{ error: 'Invalid file type. Only JPEG, PNG, GIF and WebP are allowed.' },
{ status: 400 }
);
}
// Get file extension
const fileExt = file.name.split('.').pop() || '';
// Create a unique filename
const timestamp = Date.now();
const randomId = Math.random().toString(36).substring(2, 15);
const filename = `${timestamp}-${randomId}.${fileExt}`;
// Ensure uploads directory exists
const uploadsDir = ensureUploadsDir();
const filepath = path.join(uploadsDir, filename);
// Convert file to buffer and save it
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
await writeFile(filepath, buffer);
// Return the URL to the uploaded file
const fileUrl = `/uploads/${filename}`;
return NextResponse.json({
success: 1,
file: {
url: fileUrl,
// You can add more metadata if needed
name: file.name,
size: file.size,
type: file.type
}
});
} catch (error) {
console.error('Error uploading file:', error);
return NextResponse.json(
{ error: 'Failed to upload file' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,179 @@
import { NextRequest, NextResponse } from 'next/server';
import { getDataSource, User } from '@/lib/database';
import fs from 'fs';
import path from 'path';
// GET /api/users/[id] - Get a specific user
export async function GET(
request: NextRequest,
props: { params: Promise<{ id: string }> }
) {
try {
const { id } = await props.params;
const dataSource = await getDataSource();
const userRepository = dataSource.getRepository(User);
const user = await userRepository.findOne({
where: { id: id },
select: ['id', 'username', 'avatar', 'theme', 'createdAt', 'modifiedAt']
});
if (!user) {
return NextResponse.json(
{ error: 'User not found' },
{ status: 404 }
);
}
return NextResponse.json(user);
} catch (error) {
console.error('Error fetching user:', error);
return NextResponse.json(
{ error: 'Failed to fetch user' },
{ status: 500 }
);
}
}
// PUT /api/users/[id] - Update a user
export async function PUT(
request: NextRequest,
props: { params: Promise<{ id: string }> }
) {
try {
const { id } = await props.params;
const dataSource = await getDataSource();
const userRepository = dataSource.getRepository(User);
// Find the user to update
const user = await userRepository.findOne({
where: { id: id }
});
if (!user) {
return NextResponse.json(
{ error: 'User not found' },
{ status: 404 }
);
}
const formData = await request.formData();
const username = formData.get('username') as string;
const password = formData.get('password') as string | null;
const theme = formData.get('theme') as string | null;
// Update username if provided
if (username && username !== user.username) {
// Check if the new username already exists
const existingUser = await userRepository.findOne({ where: { username } });
if (existingUser && existingUser.id !== user.id) {
return NextResponse.json(
{ error: 'Username already exists' },
{ status: 400 }
);
}
user.username = username;
}
// Update password if provided
if (password) {
user.password = password;
await user.hashPassword();
}
// Update theme if provided
if (theme) {
user.theme = theme;
}
// Handle avatar upload if provided
const avatarFile = formData.get('avatar') as File;
if (avatarFile && avatarFile.size > 0) {
// Delete old avatar file if it exists
if (user.avatar) {
const oldAvatarPath = path.join(process.cwd(), 'public', user.avatar);
if (fs.existsSync(oldAvatarPath)) {
fs.unlinkSync(oldAvatarPath);
}
}
// Create a unique filename
const fileExtension = avatarFile.name.split('.').pop();
const fileName = `${Date.now()}-${Math.random().toString(36).substring(2, 15)}.${fileExtension}`;
// Save the file to the public directory
const avatarBuffer = await avatarFile.arrayBuffer();
// Create uploads directory if it doesn't exist
const uploadDir = path.join(process.cwd(), 'public', 'uploads');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
// Write the file
fs.writeFileSync(path.join(uploadDir, fileName), Buffer.from(avatarBuffer));
// Set the avatar path to be stored in the database
user.avatar = `/uploads/${fileName}`;
}
// Save the updated user
const updatedUser = await userRepository.save(user);
// Return the user without the password
const { password: _, ...userWithoutPassword } = updatedUser;
return NextResponse.json(userWithoutPassword);
} catch (error) {
console.error('Error updating user:', error);
return NextResponse.json(
{ error: 'Failed to update user' },
{ status: 500 }
);
}
}
// DELETE /api/users/[id] - Delete a user
export async function DELETE(
request: NextRequest,
props: { params: Promise<{ id: string }> }
) {
try {
const { id } = await props.params;
const dataSource = await getDataSource();
const userRepository = dataSource.getRepository(User);
// Find the user to delete
const user = await userRepository.findOne({
where: { id: id }
});
if (!user) {
return NextResponse.json(
{ error: 'User not found' },
{ status: 404 }
);
}
// Delete avatar file if it exists
if (user.avatar) {
const avatarPath = path.join(process.cwd(), 'public', user.avatar);
if (fs.existsSync(avatarPath)) {
fs.unlinkSync(avatarPath);
}
}
// Delete the user
await userRepository.remove(user);
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error deleting user:', error);
return NextResponse.json(
{ error: 'Failed to delete user' },
{ status: 500 }
);
}
}

101
src/app/api/users/route.ts Normal file
View File

@ -0,0 +1,101 @@
import { NextRequest, NextResponse } from 'next/server';
import { getDataSource, User } from '@/lib/database';
import fs from 'fs';
import path from 'path';
// GET /api/users - Get all users
export async function GET(request: NextRequest) {
try {
const dataSource = await getDataSource();
const userRepository = dataSource.getRepository(User);
const users = await userRepository.find({
select: ['id', 'username', 'avatar', 'createdAt', 'modifiedAt'],
order: { createdAt: 'DESC' }
});
return NextResponse.json(users);
} catch (error) {
console.error('Error fetching users:', error);
return NextResponse.json(
{ error: 'Failed to fetch users' },
{ status: 500 }
);
}
}
// POST /api/users - Create a new user
export async function POST(request: NextRequest) {
try {
const dataSource = await getDataSource();
const userRepository = dataSource.getRepository(User);
const formData = await request.formData();
const username = formData.get('username') as string;
const password = formData.get('password') as string;
// Validate required fields
if (!username || !password) {
return NextResponse.json(
{ error: 'Username and password are required' },
{ status: 400 }
);
}
// Check if username already exists
const existingUser = await userRepository.findOne({ where: { username } });
if (existingUser) {
return NextResponse.json(
{ error: 'Username already exists' },
{ status: 400 }
);
}
// Handle avatar upload if provided
let avatarPath: string | null = null;
const avatarFile = formData.get('avatar') as File;
if (avatarFile && avatarFile.size > 0) {
// Create a unique filename
const fileExtension = avatarFile.name.split('.').pop();
const fileName = `${Date.now()}-${Math.random().toString(36).substring(2, 15)}.${fileExtension}`;
// Save the file to the public directory
const avatarBuffer = await avatarFile.arrayBuffer();
// Create uploads directory if it doesn't exist
const uploadDir = path.join(process.cwd(), 'public', 'uploads');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
// Write the file
fs.writeFileSync(path.join(uploadDir, fileName), Buffer.from(avatarBuffer));
// Set the avatar path to be stored in the database
avatarPath = `/uploads/${fileName}`;
}
// Create and save the new user
const user = new User();
user.username = username;
user.password = password;
user.avatar = avatarPath;
// Hash the password before saving
await user.hashPassword();
const savedUser = await userRepository.save(user);
// Return the user without the password
const { password: _, ...userWithoutPassword } = savedUser;
return NextResponse.json(userWithoutPassword, { status: 201 });
} catch (error) {
console.error('Error creating user:', error);
return NextResponse.json(
{ error: 'Failed to create user' },
{ status: 500 }
);
}
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

139
src/app/globals.css Normal file
View File

@ -0,0 +1,139 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
.dark {
--background: #0a0a0a;
--foreground: #ededed;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root:not(.light):not(.dark) {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
.codex-editor,
.ce-block__content,
.ce-toolbar__content {
max-width: 100%;
}
.codex-editor {
margin-left: 50px;
}
/* Dark mode styles */
.dark .bg-white {
background-color: #1a1a1a;
}
.dark .bg-gray-50,
.dark .bg-gray-100 {
background-color: #111111;
}
.dark .text-gray-900 {
color: #f5f5f5;
}
.dark .text-gray-800 {
color: #e5e5e5;
}
.dark .text-gray-700 {
color: #d5d5d5;
}
.dark .text-gray-600 {
color: #c5c5c5;
}
.dark .text-gray-500 {
color: #a5a5a5;
}
.dark .border-gray-300,
.dark .border-gray-200 {
border-color: #333333;
}
.dark .shadow,
.dark .shadow-md,
.dark .shadow-sm {
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.3), 0 1px 2px 0 rgba(0, 0, 0, 0.2);
}
.dark .hover\:bg-gray-100:hover {
background-color: #222222;
}
.dark .hover\:text-gray-700:hover {
color: #ffffff;
}
/* Fix text colors in dark mode */
.dark .text-black {
color: #f5f5f5;
}
/* Fix for treeview in front page */
.dark .text-sm.text-black {
color: #f5f5f5;
}
/* Fix for "Latest Posts" header */
.dark h2.text-2xl.font-bold.text-black {
color: #f5f5f5;
}
/* Fix for Post form content in admin */
.dark .ce-paragraph {
color: #f5f5f5;
}
.dark .ce-block--selected .ce-block__content,
.dark .ce-inline-toolbar,
.dark .codex-editor--narrow .ce-toolbox,
.dark .ce-conversion-toolbar,
.dark .ce-settings,
.dark .ce-settings__button,
.dark .ce-toolbar__settings-btn,
.dark .cdx-button,
.dark .ce-toolbar__plus {
background: #2a2a2a;
color: #f5f5f5;
border-color: #444;
}
.dark .ce-inline-tool,
.dark .ce-conversion-toolbar__label,
.dark .ce-toolbox__button,
.dark .cdx-settings-button,
.dark .ce-toolbar__plus svg {
color: #f5f5f5;
}
/* Fix for Post form title in admin */
.dark input[type="text"],
.dark textarea {
color: #f5f5f5;
background-color: #1a1a1a;
}

View File

@ -0,0 +1,13 @@
import { initializeDatabase } from '@/lib/database';
// This component initializes the database connection when the app starts
export default async function DatabaseInitializer() {
try {
await initializeDatabase();
// This component doesn't render anything
return null;
} catch (error) {
console.error('Failed to initialize database:', error);
return null;
}
}

View File

@ -0,0 +1,214 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import EditorJS, { OutputData } from '@editorjs/editorjs';
import Header from '@editorjs/header';
import List from '@editorjs/list';
import Paragraph from '@editorjs/paragraph';
import Quote from '@editorjs/quote';
import Code from '@editorjs/code';
import Link from '@editorjs/link';
import Marker from '@editorjs/marker';
import InlineCode from '@editorjs/inline-code';
import Image from '@editorjs/image';
interface EditorProps {
data?: any;
onChange: (data: any) => void;
placeholder?: string;
readOnly?: boolean;
}
export default function Editor({ data, onChange, placeholder, readOnly = false }: EditorProps) {
const editorRef = useRef<any | null>(null);
const holderRef = useRef<HTMLDivElement>(null);
const [isReady, setIsReady] = useState(false);
let editor: any | null = null;
console.log("render editor");
// Initialize editor
useEffect(() => {
if (!holderRef.current) return;
if (editor) return;
// Clean up previous instance
if (editorRef.current) {
console.log("cleanup")
try {
// Some versions of EditorJS might not have destroy method directly accessible
if (typeof editorRef.current.destroy === 'function') {
editorRef.current.destroy();
}
} catch (e) {
console.error('Error destroying editor:', e);
}
editorRef.current = null;
}
editor = new EditorJS({
holderId: "editor",
tools: {
header: {
class: Header,
config: {
placeholder: 'Enter a header',
levels: [2, 3, 4],
defaultLevel: 2
}
},
list: {
class: List,
inlineToolbar: true,
},
paragraph: {
class: Paragraph,
inlineToolbar: true,
},
quote: {
class: Quote,
inlineToolbar: true,
config: {
quotePlaceholder: 'Enter a quote',
captionPlaceholder: 'Quote\'s author',
},
},
code: Code,
link: {
class: Link,
config: {
endpoint: '/api/fetchUrl', // Optional endpoint for url data fetching
}
},
marker: {
class: Marker,
shortcut: 'CMD+SHIFT+M',
},
inlineCode: {
class: InlineCode,
shortcut: 'CMD+SHIFT+C',
},
image: {
class: Image,
config: {
endpoints: {
byFile: '/api/upload', // Your file upload endpoint
},
field: 'image', // Field name for the file
types: 'image/*', // Accepted file types
captionPlaceholder: 'Image caption',
uploader: {
uploadByFile(file: File) {
const formData = new FormData();
formData.append('image', file);
return fetch('/api/upload', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(result => {
if (result.success) {
return {
success: 1,
file: {
url: result.file.url,
}
};
} else {
return {
success: 0,
message: result.error || 'Upload failed'
};
}
})
.catch(error => {
console.error('Error uploading image:', error);
return {
success: 0,
message: 'Upload failed'
};
});
}
}
}
},
},
data: data && typeof data === 'string' ?
// Try to parse JSON string, or create a simple paragraph if it's plain text
tryParseJSON(data) || createSimpleParagraph(data) :
// Use the data object directly if it's already an object
data || {},
placeholder: placeholder || 'Start writing your content...',
readOnly,
onChange: async () => {
const savedData = await editor.save();
onChange(savedData);
},
});
editorRef.current = editor;
editor.isReady
.then(() => {
setIsReady(true);
})
.catch((error: Error) => {
console.error('Editor.js initialization failed:', error);
});
return () => {
// Clean up editor instance
if (editorRef.current) {
try {
// Some versions of EditorJS might not have destroy method directly accessible
if (typeof editorRef.current.destroy === 'function') {
editorRef.current.destroy();
}
} catch (e) {
console.error('Error destroying editor:', e);
}
editorRef.current = null;
}
};
}, [readOnly]); // Only re-initialize when readOnly changes
// Helper function to try parsing JSON
const tryParseJSON = (jsonString: string) => {
try {
return JSON.parse(jsonString);
} catch (e) {
return null;
}
};
// Helper function to create a simple paragraph block from plain text
const createSimpleParagraph = (text: string) => {
return {
time: new Date().getTime(),
blocks: [
{
type: 'paragraph',
data: {
text
}
}
]
};
};
return (
<div className="editor-js-container w-full">
<div
ref={holderRef}
id="editor"
className="w-full min-h-[300px] border border-gray-300 rounded-md p-4 bg-white text-black pl-5"
/>
{!isReady && (
<div className="text-gray-500 text-sm mt-2">
Loading editor...
</div>
)}
</div>
);
}

View File

@ -0,0 +1,167 @@
'use client';
import React from 'react';
interface Block {
id?: string;
type: string;
data: any;
}
interface EditorJSData {
time?: number;
blocks: Block[];
version?: string;
}
interface EditorJSRendererProps {
data: string | EditorJSData;
className?: string;
}
const EditorJSRenderer: React.FC<EditorJSRendererProps> = ({ data, className = '' }) => {
// Parse data if it's a string
const parsedData = typeof data === 'string' ? tryParseJSON(data) : data as EditorJSData;
// If parsing failed or no blocks, render the content as plain text
if (!parsedData || !parsedData.blocks || !Array.isArray(parsedData.blocks)) {
return <div className={`prose max-w-none text-black ${className}`}>{typeof data === 'string' ? data : 'No content'}</div>;
}
return (
<div className={`editorjs-renderer prose max-w-none text-black ${className}`}>
{parsedData.blocks.map((block, index) => renderBlock(block, index))}
</div>
);
};
// Helper function to try parsing JSON
const tryParseJSON = (jsonString: string): EditorJSData | null => {
try {
return JSON.parse(jsonString);
} catch (e) {
console.error('Failed to parse EditorJS data:', e);
return null;
}
};
// Render individual blocks based on their type
const renderBlock = (block: Block, index: number) => {
const { type, data } = block;
switch (type) {
case 'header':
return renderHeader(data, index);
case 'paragraph':
return renderParagraph(data, index);
case 'list':
return renderList(data, index);
case 'quote':
return renderQuote(data, index);
case 'code':
return renderCode(data, index);
case 'image':
return renderImage(data, index);
default:
return <p key={index} className="text-gray-500">Unsupported block type: {type}</p>;
}
};
// Render header block
const renderHeader = (data: any, index: number) => {
const { text, level } = data;
switch (level) {
case 1:
return <h1 key={index} className="text-3xl font-bold mt-6 mb-4" dangerouslySetInnerHTML={{ __html: text }} />;
case 2:
return <h2 key={index} className="text-2xl font-bold mt-6 mb-3" dangerouslySetInnerHTML={{ __html: text }} />;
case 3:
return <h3 key={index} className="text-xl font-bold mt-5 mb-2" dangerouslySetInnerHTML={{ __html: text }} />;
case 4:
return <h4 key={index} className="text-lg font-bold mt-4 mb-2" dangerouslySetInnerHTML={{ __html: text }} />;
case 5:
return <h5 key={index} className="text-base font-bold mt-4 mb-2" dangerouslySetInnerHTML={{ __html: text }} />;
case 6:
return <h6 key={index} className="text-sm font-bold mt-4 mb-2" dangerouslySetInnerHTML={{ __html: text }} />;
default:
return <h3 key={index} className="text-xl font-bold mt-5 mb-2" dangerouslySetInnerHTML={{ __html: text }} />;
}
};
// Render paragraph block
const renderParagraph = (data: any, index: number) => {
return <p key={index} className="my-3 text-black" dangerouslySetInnerHTML={{ __html: data.text }} />;
};
// Render list block
const renderList = (data: any, index: number) => {
const { style, items } = data;
if (style === 'ordered') {
return (
<ol key={index} className="list-decimal pl-6 my-4">
{items.map((item: string, i: number) => (
<li key={i} className="my-1 text-black" dangerouslySetInnerHTML={{ __html: item }} />
))}
</ol>
);
} else {
return (
<ul key={index} className="list-disc pl-6 my-4">
{items.map((item: string, i: number) => (
<li key={i} className="my-1 text-black" dangerouslySetInnerHTML={{ __html: item }} />
))}
</ul>
);
}
};
// Render quote block
const renderQuote = (data: any, index: number) => {
const { text, caption } = data;
return (
<blockquote key={index} className="border-l-4 border-gray-300 pl-4 py-2 my-4 italic text-black">
<p className="text-black" dangerouslySetInnerHTML={{ __html: text }} />
{caption && <cite className="block text-sm text-gray-600 mt-2"> {caption}</cite>}
</blockquote>
);
};
// Render code block
const renderCode = (data: any, index: number) => {
const { code } = data;
return (
<pre key={index} className="bg-gray-100 p-4 rounded-md overflow-x-auto my-4">
<code className="text-black">{code}</code>
</pre>
);
};
// Render image block
const renderImage = (data: any, index: number) => {
const { file, caption } = data;
if (!file || !file.url) {
return <p key={index} className="text-gray-500">Image not available</p>;
}
return (
<figure key={index} className="my-4">
<img
src={file.url}
alt={caption || 'Image'}
className="max-w-full h-auto rounded-md"
/>
{caption && (
<figcaption className="text-sm text-gray-600 mt-2 text-center">
{caption}
</figcaption>
)}
</figure>
);
};
export default EditorJSRenderer;

View File

@ -0,0 +1,57 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import UserMenu from '@/lib/components/UserMenu';
import ThemeToggle from '@/lib/components/ThemeToggle';
interface User {
id: string;
username: string;
avatar: string | null;
}
export default function FrontendHeader() {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const fetchUser = async () => {
try {
const response = await fetch('/api/auth');
const data = await response.json();
if (data.authenticated) {
setUser(data.user);
}
} catch (error) {
console.error('Error fetching user:', error);
} finally {
setIsLoading(false);
}
};
fetchUser();
}, []);
return (
<header className="bg-white shadow">
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8 flex justify-between items-center">
<h1 className="text-3xl font-bold text-gray-900">KantanCMS</h1>
<div className="flex items-center space-x-4">
{/* ThemeToggle is now in the layout, so we don't need it here */}
{!isLoading && user ? (
<UserMenu user={user} isAdmin={false} />
) : (
<Link
href="/admin"
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700"
>
Admin Panel
</Link>
)}
</div>
</div>
</header>
);
}

View File

@ -0,0 +1,91 @@
import Link from 'next/link';
import { getDataSource, Post } from '@/lib/database';
import { headers } from 'next/headers';
interface PostWithChildren extends Post {
children?: PostWithChildren[];
}
export default async function PostSidebar() {
// Get current path for highlighting active post
const headersList = await headers();
const pathname = headersList.get('x-pathname') || '';
// Fetch posts from the database
let posts: PostWithChildren[] = [];
let error = null;
try {
const dataSource = await getDataSource();
const postRepository = dataSource.getRepository(Post);
const data = await postRepository.find();
// Organize posts into a tree structure
const postsMap = new Map<string, PostWithChildren>();
const rootPosts: PostWithChildren[] = [];
// First pass: create a map of all posts
data.forEach((post) => {
postsMap.set(post.id, { ...post, children: [] });
});
// Second pass: build the tree structure
data.forEach((post) => {
const postWithChildren = postsMap.get(post.id)!;
if (post.parentId && postsMap.has(post.parentId)) {
// Add as child to parent
const parent = postsMap.get(post.parentId)!;
parent.children = parent.children || [];
parent.children.push(postWithChildren);
} else {
// Add to root posts
rootPosts.push(postWithChildren);
}
});
posts = rootPosts;
} catch (err) {
console.error('Error fetching posts:', err);
error = 'Failed to load posts';
}
// Recursive function to render post tree
const renderPostTree = (posts: PostWithChildren[], level = 0) => {
return posts.map((post) => (
<div key={post.id} className="mb-1">
<Link
href={`/posts/${post.id}`}
className={`block py-1 px-2 rounded text-sm text-black hover:bg-gray-100 ${pathname === `/posts/${post.id}` ? 'bg-gray-100 font-medium' : ''
}`}
style={{ paddingLeft: `${level * 16 + 8}px` }}
prefetch={false}
>
{post.title}
</Link>
{post.children && post.children.length > 0 && (
<div className="ml-2">
{renderPostTree(post.children, level + 1)}
</div>
)}
</div>
));
};
if (error) {
return <div className="p-4 text-red-500">{error}</div>;
}
if (posts.length === 0) {
return <div className="p-4 text-gray-500">No posts found</div>;
}
return (
<div className="bg-white rounded-lg shadow p-4">
<h3 className="text-lg font-medium text-gray-900 mb-3">Posts</h3>
<div className="max-h-[calc(100vh-200px)] overflow-y-auto">
{renderPostTree(posts)}
</div>
</div>
);
}

View File

@ -0,0 +1,285 @@
'use client';
import { useState, useEffect, FormEvent, ChangeEvent } from 'react';
import { useRouter } from 'next/navigation';
import Image from 'next/image';
interface User {
id: string;
username: string;
avatar: string | null;
theme: string;
createdAt: string;
modifiedAt: string;
}
interface ProfileEditorProps {
isAdmin?: boolean;
}
export default function ProfileEditor({ isAdmin = false }: ProfileEditorProps) {
const router = useRouter();
const [user, setUser] = useState<User | null>(null);
const [formData, setFormData] = useState({
username: '',
password: '', // Optional for updates
theme: 'system', // Default to system
});
const [avatar, setAvatar] = useState<File | null>(null);
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
// Fetch current user data
useEffect(() => {
const fetchUser = async () => {
try {
const response = await fetch('/api/auth');
const data = await response.json();
if (!data.authenticated) {
router.push(isAdmin ? '/admin/login' : '/');
return;
}
setUser(data.user);
setFormData({
username: data.user.username,
password: '', // Don't populate password
theme: data.user.theme || 'system',
});
if (data.user.avatar) {
setAvatarPreview(data.user.avatar);
}
setIsLoading(false);
} catch (err) {
setError('Failed to load user data');
setIsLoading(false);
}
};
fetchUser();
}, [router, isAdmin]);
const handleChange = (e: ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
const handleAvatarChange = (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
const file = e.target.files[0];
setAvatar(file);
// Create a preview URL
const reader = new FileReader();
reader.onloadend = () => {
setAvatarPreview(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
setError(null);
setSuccessMessage(null);
try {
// Validate form
if (!formData.username) {
throw new Error('Username is required');
}
if (!user) {
throw new Error('User not found');
}
// Create form data for submission
const submitData = new FormData();
submitData.append('username', formData.username);
submitData.append('theme', formData.theme);
// Only include password if it was changed
if (formData.password) {
submitData.append('password', formData.password);
}
// Only include avatar if a new one was selected
if (avatar) {
submitData.append('avatar', avatar);
}
// Submit the form
const response = await fetch(`/api/users/${user.id}`, {
method: 'PUT',
body: submitData,
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to update profile');
}
// Update the user data
const updatedUser = await response.json();
setUser(updatedUser);
// Show success message
setSuccessMessage('Profile updated successfully');
// Clear password field
setFormData(prev => ({ ...prev, password: '' }));
// Refresh the page to update the UI
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setIsSubmitting(false);
}
};
if (isLoading) {
return <div className="text-center py-10">Loading...</div>;
}
return (
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-semibold text-gray-900">Edit Profile</h1>
</div>
{error && (
<div className="bg-red-50 border-l-4 border-red-400 p-4 mb-6">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<p className="text-sm text-red-700">{error}</p>
</div>
</div>
</div>
)}
{successMessage && (
<div className="bg-green-50 border-l-4 border-green-400 p-4 mb-6">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<p className="text-sm text-green-700">{successMessage}</p>
</div>
</div>
</div>
)}
<form onSubmit={handleSubmit} className="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4">
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="username">
Username
</label>
<input
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
id="username"
type="text"
name="username"
value={formData.username}
onChange={handleChange}
placeholder="Username"
required
/>
</div>
<div className="mb-6">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="password">
Password (leave blank to keep current password)
</label>
<input
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
id="password"
type="password"
name="password"
value={formData.password}
onChange={handleChange}
placeholder="New password (optional)"
/>
</div>
<div className="mb-6">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="theme">
Theme
</label>
<select
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
id="theme"
name="theme"
value={formData.theme}
onChange={handleChange}
>
<option value="system">System (follow device settings)</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
<p className="text-sm text-gray-500 mt-1">
Choose your preferred theme or use your system settings.
</p>
</div>
<div className="mb-6">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="avatar">
Avatar
</label>
<input
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
id="avatar"
type="file"
name="avatar"
onChange={handleAvatarChange}
accept="image/*"
/>
{avatarPreview && (
<div className="mt-2">
<Image
src={avatarPreview}
alt="Avatar preview"
width={80}
height={80}
className="rounded-full object-cover"
/>
</div>
)}
</div>
<div className="flex items-center justify-between">
<button
className="bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline disabled:opacity-50"
type="submit"
disabled={isSubmitting}
>
{isSubmitting ? 'Updating...' : 'Update Profile'}
</button>
<button
type="button"
onClick={() => router.back()}
className="inline-block align-baseline font-bold text-sm text-indigo-600 hover:text-indigo-800"
>
Cancel
</button>
</div>
</form>
</div>
);
}

View File

@ -0,0 +1,59 @@
'use client';
import { useTheme } from '@/lib/context/ThemeContext';
import { useEffect, useState } from 'react';
interface ThemeToggleProps {
className?: string;
}
export default function ThemeToggle({ className = '' }: ThemeToggleProps) {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
// Avoid hydration mismatch by only rendering after mount
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return null;
}
return (
<div className={`flex items-center ${className}`}>
<button
type="button"
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
className="p-2 rounded-md text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500"
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
>
{theme === 'dark' ? (
// Sun icon for dark mode (switch to light)
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"
clipRule="evenodd"
/>
</svg>
) : (
// Moon icon for light mode (switch to dark)
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
</svg>
)}
</button>
</div>
);
}

View File

@ -0,0 +1,11 @@
'use client';
import ThemeToggle from './ThemeToggle';
export default function ThemeToggleWrapper() {
return (
<div className="fixed top-4 right-4 z-50">
<ThemeToggle />
</div>
);
}

View File

@ -0,0 +1,135 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { useRouter } from 'next/navigation';
interface User {
id: string;
username: string;
avatar: string | null;
}
interface UserMenuProps {
user: User;
isAdmin?: boolean;
}
export default function UserMenu({ user, isAdmin = false }: UserMenuProps) {
const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const router = useRouter();
// Close the menu when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
const handleLogout = async () => {
try {
await fetch('/api/auth', {
method: 'DELETE',
});
// Always redirect to frontend on logout
router.push('/');
router.refresh();
} catch (error) {
console.error('Logout error:', error);
}
};
const profilePath = isAdmin ? `/admin/profile` : `/profile`;
return (
<div className="relative" ref={menuRef}>
<button
className="flex items-center focus:outline-none"
onClick={() => setIsOpen(!isOpen)}
aria-expanded={isOpen}
aria-haspopup="true"
>
<div className="flex-shrink-0 h-8 w-8 rounded-full bg-gray-200 flex items-center justify-center overflow-hidden">
{user.avatar ? (
<Image
src={user.avatar}
alt={user.username}
width={32}
height={32}
className="object-cover"
/>
) : (
<span className="text-gray-500 text-sm font-medium">
{user.username.charAt(0).toUpperCase()}
</span>
)}
</div>
<span className="ml-2 text-sm font-medium text-gray-700 hidden sm:block">
{user.username}
</span>
<svg
className="ml-1 h-5 w-5 text-gray-400"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</button>
{isOpen && (
<div className="origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 z-10">
<div
className="py-1"
role="menu"
aria-orientation="vertical"
aria-labelledby="user-menu"
>
<Link
href={profilePath}
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
role="menuitem"
onClick={() => setIsOpen(false)}
>
Edit Profile
</Link>
{!isAdmin && (
<Link
href="/admin"
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
role="menuitem"
onClick={() => setIsOpen(false)}
>
Admin Console
</Link>
)}
<button
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 cursor-pointer"
role="menuitem"
onClick={() => {
setIsOpen(false);
handleLogout();
}}
>
Logout
</button>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,81 @@
'use client';
import React, { createContext, useContext, useEffect, useState } from 'react';
type Theme = 'light' | 'dark' | 'system';
interface ThemeContextType {
theme: Theme;
setTheme: (theme: Theme) => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>('system');
const [mounted, setMounted] = useState(false);
// Load theme from localStorage on client side
useEffect(() => {
const storedTheme = localStorage.getItem('theme') as Theme | null;
if (storedTheme) {
setTheme(storedTheme);
}
setMounted(true);
}, []);
// Update localStorage and document class when theme changes
useEffect(() => {
if (!mounted) return;
localStorage.setItem('theme', theme);
const root = document.documentElement;
root.classList.remove('light', 'dark');
if (theme === 'system') {
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
root.classList.add(systemTheme);
} else {
root.classList.add(theme);
}
}, [theme, mounted]);
// Listen for system theme changes
useEffect(() => {
if (!mounted) return;
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = () => {
if (theme === 'system') {
const systemTheme = mediaQuery.matches ? 'dark' : 'light';
document.documentElement.classList.remove('light', 'dark');
document.documentElement.classList.add(systemTheme);
}
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [theme, mounted]);
// Prevent flash of incorrect theme
if (!mounted) {
// fix this
//return <>{children}</>;
}
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}

View File

@ -0,0 +1,57 @@
import { DataSource, DataSourceOptions } from 'typeorm';
import { User } from './entities/User';
import { Post } from './entities/Post';
import path from 'path';
// Default configuration for SQLite (development/testing)
const sqliteConfig: DataSourceOptions = {
type: 'sqlite',
database: path.join(process.cwd(), 'data', 'database.sqlite'),
entities: [User, Post],
synchronize: true, // Set to false in production
logging: process.env.NODE_ENV === 'development',
};
// MySQL configuration
const mysqlConfig: DataSourceOptions = {
type: 'mysql',
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '3306'),
username: process.env.DB_USERNAME || 'root',
password: process.env.DB_PASSWORD || '',
database: process.env.DB_DATABASE || 'kantancms',
entities: [User, Post],
synchronize: false, // Always false in production
logging: process.env.NODE_ENV === 'development',
};
// PostgreSQL configuration
const postgresConfig: DataSourceOptions = {
type: 'postgres',
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432'),
username: process.env.DB_USERNAME || 'postgres',
password: process.env.DB_PASSWORD || '',
database: process.env.DB_DATABASE || 'kantancms',
entities: [User, Post],
synchronize: false, // Always false in production
logging: process.env.NODE_ENV === 'development',
};
// Select the database configuration based on environment variable
const getConfig = (): DataSourceOptions => {
const dbType = process.env.DB_TYPE || 'sqlite';
switch (dbType) {
case 'mysql':
return mysqlConfig;
case 'postgres':
return postgresConfig;
case 'sqlite':
default:
return sqliteConfig;
}
};
// Create and export the DataSource
export const AppDataSource = new DataSource(getConfig());

View File

@ -0,0 +1,35 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn } from 'typeorm';
// Forward reference to User to avoid circular dependency
import type { User } from './User';
@Entity('posts')
export class Post {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ nullable: true })
parentId: string;
@Column()
userId: string;
@Column()
title: string;
@Column('text')
content: string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
modifiedAt: Date;
@ManyToOne(() => Post, post => post.id, { nullable: true })
@JoinColumn({ name: 'parentId' })
parent: Post;
@ManyToOne('User', 'posts')
@JoinColumn({ name: 'userId' })
user: User;
}

View File

@ -0,0 +1,41 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, OneToMany } from 'typeorm';
// Forward reference to Post to avoid circular dependency
import type { Post } from './Post';
import * as bcrypt from 'bcrypt';
@Entity('users')
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ unique: true })
username: string;
@Column()
password: string;
@Column({ nullable: true, type: 'varchar', default: null })
avatar: string | null;
@Column({ type: 'varchar', default: 'system' })
theme: string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
modifiedAt: Date;
@OneToMany('Post', 'user')
posts: Post[];
// Method to hash password before saving
async hashPassword() {
this.password = await bcrypt.hash(this.password, 10);
}
// Method to validate password
async validatePassword(password: string): Promise<boolean> {
return bcrypt.compare(password, this.password);
}
}

36
src/lib/database/index.ts Normal file
View File

@ -0,0 +1,36 @@
import 'reflect-metadata';
import { AppDataSource } from './config';
import fs from 'fs';
import path from 'path';
// Ensure data directory exists for SQLite
const dataDir = path.join(process.cwd(), 'data');
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
// Initialize database connection
export const initializeDatabase = async () => {
try {
if (!AppDataSource.isInitialized) {
await AppDataSource.initialize();
console.log('Database connection established successfully');
}
return AppDataSource;
} catch (error) {
console.error('Error during database initialization:', error);
throw error;
}
};
// Get the initialized data source
export const getDataSource = async () => {
if (!AppDataSource.isInitialized) {
await initializeDatabase();
}
return AppDataSource;
};
// Export entities - Post must be exported after User to resolve circular dependency
export * from './entities/User';
export * from './entities/Post';

65
src/middleware.ts Normal file
View File

@ -0,0 +1,65 @@
import { NextRequest, NextResponse } from 'next/server';
// Paths that don't require authentication
const publicPaths = ['/admin/login'];
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Add current path to headers for server components
const response = NextResponse.next({
request: {
headers: new Headers(request.headers),
},
});
response.headers.set('x-pathname', pathname);
// Only apply auth middleware to admin routes
if (!pathname.startsWith('/admin')) {
return response;
}
// Allow access to public paths
if (publicPaths.includes(pathname)) {
return response;
}
// Check for auth cookie
const authCookie = request.cookies.get('auth');
// If no auth cookie, redirect to login
if (!authCookie?.value) {
const url = new URL('/admin/login', request.url);
return NextResponse.redirect(url);
}
// Verify the auth cookie by calling the auth API
try {
const authResponse = await fetch(new URL('/api/auth', request.url), {
headers: {
Cookie: `auth=${authCookie.value}`,
},
});
const data = await authResponse.json();
if (!data.authenticated) {
const url = new URL('/admin/login', request.url);
return NextResponse.redirect(url);
}
} catch (error) {
console.error('Auth verification error:', error);
const url = new URL('/admin/login', request.url);
return NextResponse.redirect(url);
}
return response;
}
// Configure middleware to run on all routes
export const config = {
matcher: [
// Apply to all routes
'/((?!api|_next/static|_next/image|favicon.ico).*)',
],
};

View File

@ -0,0 +1,42 @@
import { getDataSource, User } from '../lib/database';
async function createTestUser() {
try {
// Initialize database connection
const dataSource = await getDataSource();
const userRepository = dataSource.getRepository(User);
// Check if test user already exists
const existingUser = await userRepository.findOne({
where: { username: 'admin' }
});
if (existingUser) {
console.log('Test user already exists');
return;
}
// Create a new user
const user = new User();
user.username = 'admin';
user.password = 'password';
user.theme = 'system'; // Default to system theme
// Hash the password
await user.hashPassword();
// Save the user
await userRepository.save(user);
console.log('Test user created successfully');
} catch (error) {
console.error('Error creating test user:', error);
} finally {
// Close the connection
const dataSource = await getDataSource();
await dataSource.destroy();
}
}
// Run the function
createTestUser();

View File

@ -0,0 +1,37 @@
import { getDataSource, User } from '../lib/database';
async function createTestUser() {
try {
console.log('Connecting to database...');
const dataSource = await getDataSource();
console.log('Checking if test user exists...');
const userRepository = dataSource.getRepository(User);
const existingUser = await userRepository.findOne({ where: { username: 'admin' } });
if (existingUser) {
console.log('Test user already exists.');
await userRepository.delete({ id: existingUser.id });
console.log('Deleted existing test user.');
}
console.log('Creating test user...');
const user = new User();
user.username = 'admin';
user.password = 'password';
// Hash the password
await user.hashPassword();
// Save the user
await userRepository.save(user);
console.log('Test user created successfully.');
} catch (error) {
console.error('Error creating test user:', error);
} finally {
process.exit(0);
}
}
createTestUser();

View File

@ -0,0 +1,91 @@
import fs from 'fs';
import path from 'path';
import { getDataSource, User, Post } from '../lib/database';
async function resetDatabase() {
try {
console.log('Resetting database...');
// Path to the SQLite database file
const dbPath = path.join(process.cwd(), 'data', 'database.sqlite');
// Check if the database file exists
if (fs.existsSync(dbPath)) {
console.log('Deleting existing database file...');
// Delete the database file
fs.unlinkSync(dbPath);
console.log('Database file deleted.');
}
// Initialize a new database connection
// This will create a new database file with the updated schema
console.log('Initializing new database...');
const dataSource = await getDataSource();
// Create a test user
console.log('Creating test user...');
const userRepository = dataSource.getRepository(User);
const user = new User();
user.username = 'admin';
user.password = 'password';
user.theme = 'system'; // Default to system theme
// Hash the password
await user.hashPassword();
// Save the user
await userRepository.save(user);
console.log('Test user created successfully.');
console.log('Username: admin');
console.log('Password: password');
// Create a sample post
console.log('Creating sample post...');
const postRepository = dataSource.getRepository(Post);
const post = new Post();
post.title = 'Welcome to KantanCMS';
post.content = JSON.stringify({
time: new Date().getTime(),
blocks: [
{
type: 'header',
data: {
text: 'Welcome to KantanCMS',
level: 2
}
},
{
type: 'paragraph',
data: {
text: 'This is a sample post created by the database reset script. You can edit or delete this post from the admin panel.'
}
}
]
});
post.user = user;
await postRepository.save(post);
console.log('Sample post created successfully.');
console.log('Database reset complete!');
} catch (error) {
console.error('Error resetting database:', error);
} finally {
// Close the connection
try {
const dataSource = await getDataSource();
if (dataSource.isInitialized) {
await dataSource.destroy();
}
} catch (error) {
console.error('Error closing database connection:', error);
}
}
}
// Run the function
resetDatabase();

10
src/types/editorjs.d.ts vendored Normal file
View File

@ -0,0 +1,10 @@
declare module '@editorjs/editorjs';
declare module '@editorjs/header';
declare module '@editorjs/list';
declare module '@editorjs/paragraph';
declare module '@editorjs/image';
declare module '@editorjs/quote';
declare module '@editorjs/code';
declare module '@editorjs/link';
declare module '@editorjs/marker';
declare module '@editorjs/inline-code';