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;
}