initial commit
This commit is contained in:
133
src/app/(admin)/admin/layout.tsx
Normal file
133
src/app/(admin)/admin/layout.tsx
Normal 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>
|
||||
|
||||
);
|
||||
}
|
||||
130
src/app/(admin)/admin/login/page.tsx
Normal file
130
src/app/(admin)/admin/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
src/app/(admin)/admin/logout/page.tsx
Normal file
37
src/app/(admin)/admin/logout/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
6
src/app/(admin)/admin/metadata.ts
Normal file
6
src/app/(admin)/admin/metadata.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'KantanCMS Admin',
|
||||
description: 'Admin dashboard for KantanCMS',
|
||||
};
|
||||
82
src/app/(admin)/admin/page.tsx
Normal file
82
src/app/(admin)/admin/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
src/app/(admin)/admin/posts/DeleteButton.tsx
Normal file
50
src/app/(admin)/admin/posts/DeleteButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
232
src/app/(admin)/admin/posts/edit/[id]/page.tsx
Normal file
232
src/app/(admin)/admin/posts/edit/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
187
src/app/(admin)/admin/posts/new/page.tsx
Normal file
187
src/app/(admin)/admin/posts/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
104
src/app/(admin)/admin/posts/page.tsx
Normal file
104
src/app/(admin)/admin/posts/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
src/app/(admin)/admin/profile/page.tsx
Normal file
11
src/app/(admin)/admin/profile/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
src/app/(admin)/admin/users/DeleteButton.tsx
Normal file
57
src/app/(admin)/admin/users/DeleteButton.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
234
src/app/(admin)/admin/users/components/EditUser.tsx
Normal file
234
src/app/(admin)/admin/users/components/EditUser.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
6
src/app/(admin)/admin/users/edit/[id]/page.tsx
Normal file
6
src/app/(admin)/admin/users/edit/[id]/page.tsx
Normal 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} />;
|
||||
}
|
||||
182
src/app/(admin)/admin/users/new/page.tsx
Normal file
182
src/app/(admin)/admin/users/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
118
src/app/(admin)/admin/users/page.tsx
Normal file
118
src/app/(admin)/admin/users/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
src/app/(front)/layout.tsx
Normal file
42
src/app/(front)/layout.tsx
Normal 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
102
src/app/(front)/page.tsx
Normal 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">
|
||||
© {new Date().getFullYear()} KantanCMS. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
108
src/app/(front)/posts/[id]/page.tsx
Normal file
108
src/app/(front)/posts/[id]/page.tsx
Normal 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">
|
||||
© {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">
|
||||
© {new Date().getFullYear()} KantanCMS. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
src/app/(front)/profile/page.tsx
Normal file
38
src/app/(front)/profile/page.tsx
Normal 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">
|
||||
© {new Date().getFullYear()} KantanCMS. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
124
src/app/api/auth/route.ts
Normal file
124
src/app/api/auth/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
113
src/app/api/posts/[id]/route.ts
Normal file
113
src/app/api/posts/[id]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
78
src/app/api/posts/route.ts
Normal file
78
src/app/api/posts/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
76
src/app/api/upload/route.ts
Normal file
76
src/app/api/upload/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
179
src/app/api/users/[id]/route.ts
Normal file
179
src/app/api/users/[id]/route.ts
Normal 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
101
src/app/api/users/route.ts
Normal 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
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
139
src/app/globals.css
Normal file
139
src/app/globals.css
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user