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

21
Dockerfile Normal file
View File

@ -0,0 +1,21 @@
FROM node:20-alpine
WORKDIR /app
# Copy package.json and package-lock.json
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy the rest of the application
COPY . .
# Build the Next.js application
RUN npm run build
# Expose the port the app will run on
EXPOSE 3000
# Start the application
CMD ["npm", "start"]

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 kenyasue
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -1,2 +1,86 @@
# coldemailer
# KantanCMS
A simple CMS with admin console and frontpage built with Next.js 14, TypeORM, and Tailwind CSS.
## Features
- Next.js 14 with App Router
- TypeORM for database management
- Tailwind CSS for styling
- Docker Compose for development environment
- Support for multiple databases (MySQL, PostgreSQL, SQLite)
## Database Structure
- **User**: id, username, password, avatar, modifiedAt, createdAt
- **Posts**: id, parentId, userId, title, content, modifiedAt, createdAt
## Getting Started
### Prerequisites
- Node.js 18+ and npm
- Docker and Docker Compose (optional, for running MySQL/PostgreSQL)
### Installation
1. Clone the repository
```bash
git clone <repository-url>
cd kantancms
```
2. Install dependencies
```bash
npm install
```
3. Copy the environment file and configure it
```bash
cp .env.example .env
```
4. Start the development server
```bash
npm run dev
```
5. Open [http://localhost:3000](http://localhost:3000) in your browser
### Using Docker
1. Start the Docker containers
```bash
docker-compose up -d
```
2. Access the application at [http://localhost:3000](http://localhost:3000)
## Database Configuration
By default, the application uses SQLite for development and testing. You can switch to MySQL or PostgreSQL by changing the `DB_TYPE` environment variable in your `.env` file or in the `docker-compose.yml` file.
## Development
This project uses the `dev` branch for development. The `main` branch is reserved for stable releases.
```bash
# Switch to the dev branch
git checkout dev
# Create a new feature branch
git checkout -b feature/your-feature-name
# After making changes, commit and push to your feature branch
git add .
git commit -m "Description of your changes"
git push origin feature/your-feature-name
# When ready, merge to dev branch
git checkout dev
git merge feature/your-feature-name
```
## License
This project is licensed under the MIT License - see the LICENSE file for details.

View File

@ -0,0 +1,18 @@
Please generate simple project.
This project is simple cms with admin console and frontpage.
Please just generate boilerplate which support following lib and features first
- Tech stack
-- NextJS14 - App Router
-- TypeORM
-- Tailwind css
-- Docker Compose
- Database
-- Support MySQL,Postgresql,Sqllite, use SQLite for testing and for development
-- Table structure
--- User ( id, username ,password, avatar, modifiedAt, createdAt)
--- Posts ( id, parentId, userId, title, content, modifiedAt, createdAt )
- Git
-- Work in dev branch so I can manually push to main when everything works

View File

@ -0,0 +1,2 @@
Please add CRUD operation in admin console for User table.
I want enable to upload photo for avatar.

View File

@ -0,0 +1 @@
Please add authentification with username and password to login into the admin console.

View File

@ -0,0 +1,10 @@
Please make following changes
- Make feature branch - features/edit_profile
- Add user icon in header to both frontend and backend
- When user click the icon the menu opened.
- Edit profile
- logout
- Users can edit their profile in Edit Profile screen.
- Users can also upload their avatar in the screen.
- Reuser component for both admin and in front
- Commit, Push, send PR

View File

@ -0,0 +1,7 @@
Please make following changes
- Make feature branch - features/post_crud
- Add CRUD operation to Post model in admin console
- Use big textbox for content
- Admins can select parent post to organize in tree structure
- Change front page to show post in tree structure in sidebar
- When user click post on sidebar move to detail page for the post

View File

@ -0,0 +1,4 @@
Please make following changes
- checkout to branch features/editorjs
- Please change content editor for Post mode to editor.js
- Change render logic for frontend to render editor.js content properly

View File

@ -0,0 +1,7 @@
Please make following changes
- Make feature branch features/darkmode
- Add dark mode to tailwind theme
- Change automatically dark mode or light mode based on browser or os settings.
- User can select dark or light mode in profile edit
- Settings are saved in database
- Commit changes and push

View File

@ -0,0 +1,5 @@
Please make following changes
- generate feature branch features/imageupload from dev branch
- I want add upload image feature to editor.js
- Use @editorjs/image
- Generate api to upload and show image

49
docker-compose.yml Normal file
View File

@ -0,0 +1,49 @@
version: '3.8'
services:
# Next.js application
app:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
volumes:
- .:/app
- /app/node_modules
environment:
- NODE_ENV=development
- DB_TYPE=sqlite # Change to mysql or postgres to use those databases
depends_on:
- mysql
- postgres
# MySQL database
mysql:
image: mysql:8.0
ports:
- "3306:3306"
environment:
- MYSQL_ROOT_PASSWORD=password
- MYSQL_DATABASE=kantancms
- MYSQL_USER=kantancms
- MYSQL_PASSWORD=password
volumes:
- mysql_data:/var/lib/mysql
command: --default-authentication-plugin=mysql_native_password
# PostgreSQL database
postgres:
image: postgres:15
ports:
- "5432:5432"
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=password
- POSTGRES_DB=kantancms
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
mysql_data:
postgres_data:

21
eslint.config.mjs Normal file
View File

@ -0,0 +1,21 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
{
rules: {
"@typescript-eslint/no-unused-vars": "off" // Disable the no-unused-vars rule
}
}
];
export default eslintConfig;

7
next.config.ts Normal file
View File

@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

8442
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

49
package.json Normal file
View File

@ -0,0 +1,49 @@
{
"name": "kantancms",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"create-test-user": "npx ts-node -P tsconfig.scripts.json src/scripts/create-test-user.ts",
"reset-database": "npx ts-node -P tsconfig.scripts.json src/scripts/reset-database.ts"
},
"dependencies": {
"@editorjs/code": "^2.9.3",
"@editorjs/editorjs": "^2.30.8",
"@editorjs/header": "^2.8.8",
"@editorjs/image": "^2.10.2",
"@editorjs/inline-code": "^1.5.1",
"@editorjs/link": "^2.6.2",
"@editorjs/list": "^2.0.6",
"@editorjs/marker": "^1.4.0",
"@editorjs/paragraph": "^2.11.7",
"@editorjs/quote": "^2.7.6",
"bcrypt": "^5.1.1",
"jsonwebtoken": "^9.0.2",
"mysql2": "^3.13.0",
"next": "15.2.2",
"pg": "^8.14.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"reflect-metadata": "^0.2.2",
"sqlite3": "^5.1.7",
"typeorm": "^0.3.21"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/bcrypt": "^5.0.2",
"@types/jsonwebtoken": "^9.0.9",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.2.2",
"tailwindcss": "^4",
"ts-node": "^10.9.2",
"typescript": "^5"
}
}

5
postcss.config.mjs Normal file
View File

@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;

1
public/file.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
public/next.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

7
public/uploads/README.md Normal file
View File

@ -0,0 +1,7 @@
# Uploads Directory
This directory is used to store uploaded images for the EditorJS image tool.
Images uploaded through the editor will be stored here and served as static assets.
**Note:** This directory should be included in version control, but the uploaded files should be ignored.

1
public/vercel.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

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

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

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

65
src/middleware.ts Normal file
View File

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

View File

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

View File

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

View File

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

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

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

43
tsconfig.json Normal file
View File

@ -0,0 +1,43 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"strictPropertyInitialization": false,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}

11
tsconfig.scripts.json Normal file
View File

@ -0,0 +1,11 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
// If you want to compile to CommonJS and keep it simple:
"module": "CommonJS",
// or if you prefer ESM, do "module": "ESNext" or "module": "NodeNext",
// but often CommonJS is simpler for scripts.
"outDir": "dist-scripts",
"noEmit": true
}
}