initial commit
This commit is contained in:
21
Dockerfile
Normal file
21
Dockerfile
Normal 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
21
LICENSE
Normal 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.
|
||||
86
README.md
86
README.md
@ -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.
|
||||
|
||||
18
doc/prompts/0.boilerplate.txt
Normal file
18
doc/prompts/0.boilerplate.txt
Normal 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
|
||||
2
doc/prompts/1.add user management.txt
Normal file
2
doc/prompts/1.add user management.txt
Normal file
@ -0,0 +1,2 @@
|
||||
Please add CRUD operation in admin console for User table.
|
||||
I want enable to upload photo for avatar.
|
||||
1
doc/prompts/2.add authentification.txt
Normal file
1
doc/prompts/2.add authentification.txt
Normal file
@ -0,0 +1 @@
|
||||
Please add authentification with username and password to login into the admin console.
|
||||
10
doc/prompts/3.avatar upload.txt
Normal file
10
doc/prompts/3.avatar upload.txt
Normal 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
|
||||
7
doc/prompts/4.add post management.txt
Normal file
7
doc/prompts/4.add post management.txt
Normal 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
|
||||
4
doc/prompts/5.editorjs support.txt
Normal file
4
doc/prompts/5.editorjs support.txt
Normal 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
|
||||
7
doc/prompts/6.dark modde support.txt
Normal file
7
doc/prompts/6.dark modde support.txt
Normal 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
|
||||
5
doc/prompts/7. upload image.txt
Normal file
5
doc/prompts/7. upload image.txt
Normal 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
49
docker-compose.yml
Normal 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
21
eslint.config.mjs
Normal 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
7
next.config.ts
Normal 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
8442
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
49
package.json
Normal file
49
package.json
Normal 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
5
postcss.config.mjs
Normal file
@ -0,0 +1,5 @@
|
||||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
public/file.svg
Normal file
1
public/file.svg
Normal 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
1
public/globe.svg
Normal 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
1
public/next.svg
Normal 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
7
public/uploads/README.md
Normal 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
1
public/vercel.svg
Normal 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
1
public/window.svg
Normal 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 |
133
src/app/(admin)/admin/layout.tsx
Normal file
133
src/app/(admin)/admin/layout.tsx
Normal file
@ -0,0 +1,133 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "../../globals.css";
|
||||
import DatabaseInitializer from "@/lib/components/DatabaseInitializer";
|
||||
import UserMenu from "@/lib/components/UserMenu";
|
||||
import ThemeToggle from "@/lib/components/ThemeToggle";
|
||||
import { ThemeProvider } from "@/lib/context/ThemeContext";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/auth');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.authenticated) {
|
||||
setUser(data.user);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching user:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUser();
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<html><body>
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||
<div className="text-center">
|
||||
<p className="text-gray-500">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<ThemeProvider>
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<nav className="bg-white shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between h-16">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0 flex items-center">
|
||||
<Link href="/admin" className="text-xl font-bold text-gray-800">
|
||||
KantanCMS Admin
|
||||
</Link>
|
||||
</div>
|
||||
<div className="hidden sm:ml-6 sm:flex sm:space-x-8">
|
||||
<Link
|
||||
href="/admin"
|
||||
className="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/posts"
|
||||
className="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
|
||||
>
|
||||
Posts
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/users"
|
||||
className="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
|
||||
>
|
||||
Users
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden sm:ml-6 sm:flex sm:items-center">
|
||||
<ThemeToggle className="mr-4" />
|
||||
{user && (
|
||||
<UserMenu user={user} isAdmin={true} />
|
||||
)}
|
||||
<Link
|
||||
href="/"
|
||||
className="text-gray-500 hover:text-gray-700 px-3 py-2 rounded-md text-sm font-medium ml-4"
|
||||
>
|
||||
View Site
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="py-10">
|
||||
<main>
|
||||
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div className="px-4 py-8 sm:px-0">{children}</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
</body></html>
|
||||
|
||||
);
|
||||
}
|
||||
130
src/app/(admin)/admin/login/page.tsx
Normal file
130
src/app/(admin)/admin/login/page.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
'use client';
|
||||
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function AdminLogin() {
|
||||
const router = useRouter();
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
password: '',
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Login failed');
|
||||
}
|
||||
|
||||
// Redirect to admin dashboard on success
|
||||
router.push('/admin');
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
KantanCMS Admin
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
Sign in to your account
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border-l-4 border-red-400 p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
<div className="rounded-md shadow-sm -space-y-px">
|
||||
<div>
|
||||
<label htmlFor="username" className="sr-only">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Username"
|
||||
value={formData.username}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="sr-only">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Password"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="text-center">
|
||||
<Link href="/" className="text-sm text-indigo-600 hover:text-indigo-500">
|
||||
Return to site
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
src/app/(admin)/admin/logout/page.tsx
Normal file
37
src/app/(admin)/admin/logout/page.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function Logout() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const performLogout = async () => {
|
||||
try {
|
||||
await fetch('/api/auth', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
// Redirect to login page
|
||||
router.push('/admin/login');
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
// Redirect to login page even if there's an error
|
||||
router.push('/admin/login');
|
||||
}
|
||||
};
|
||||
|
||||
performLogout();
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-semibold mb-2">Logging out...</h2>
|
||||
<p className="text-gray-500">Please wait while we log you out.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
src/app/(admin)/admin/metadata.ts
Normal file
6
src/app/(admin)/admin/metadata.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'KantanCMS Admin',
|
||||
description: 'Admin dashboard for KantanCMS',
|
||||
};
|
||||
82
src/app/(admin)/admin/page.tsx
Normal file
82
src/app/(admin)/admin/page.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
export default async function AdminDashboard() {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Dashboard</h1>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{/* Posts card */}
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0 bg-indigo-500 rounded-md p-3">
|
||||
<svg className="h-6 w-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">Posts</dt>
|
||||
<dd>
|
||||
<div className="text-lg font-medium text-gray-900">--</div>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 px-4 py-4 sm:px-6">
|
||||
<div className="text-sm">
|
||||
<Link href="/admin/posts" className="font-medium text-indigo-600 hover:text-indigo-500">
|
||||
View all posts
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Users card */}
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0 bg-green-500 rounded-md p-3">
|
||||
<svg className="h-6 w-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">Users</dt>
|
||||
<dd>
|
||||
<div className="text-lg font-medium text-gray-900">--</div>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 px-4 py-4 sm:px-6">
|
||||
<div className="text-sm">
|
||||
<Link href="/admin/users" className="font-medium text-indigo-600 hover:text-indigo-500">
|
||||
View all users
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick actions card */}
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900">Quick Actions</h3>
|
||||
<div className="mt-4 space-y-2">
|
||||
<Link href="/admin/posts/new" className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
Create New Post
|
||||
</Link>
|
||||
<Link href="/admin/users/new" className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 ml-3">
|
||||
Add New User
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
src/app/(admin)/admin/posts/DeleteButton.tsx
Normal file
50
src/app/(admin)/admin/posts/DeleteButton.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
interface DeleteButtonProps {
|
||||
postId: string;
|
||||
}
|
||||
|
||||
export default function DeleteButton({ postId }: DeleteButtonProps) {
|
||||
const router = useRouter();
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm('Are you sure you want to delete this post? This action cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDeleting(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/posts/${postId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to delete post');
|
||||
}
|
||||
|
||||
// Refresh the page to show updated list
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
console.error('Error deleting post:', error);
|
||||
alert('Failed to delete post. Please try again.');
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className="text-red-600 hover:text-red-900"
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
232
src/app/(admin)/admin/posts/edit/[id]/page.tsx
Normal file
232
src/app/(admin)/admin/posts/edit/[id]/page.tsx
Normal file
@ -0,0 +1,232 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Editor from '@/lib/components/EditorJS';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface Post {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
parentId: string | null;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
interface EditPostProps {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function EditPost({ params }: EditPostProps) {
|
||||
const router = useRouter();
|
||||
const [title, setTitle] = useState('');
|
||||
const [content, setContent] = useState<any>({});
|
||||
const [parentId, setParentId] = useState<string | null>(null);
|
||||
const [userId, setUserId] = useState<string>('');
|
||||
const [posts, setPosts] = useState<Post[]>([]);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Fetch post data and options for dropdowns
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// Fetch the post to edit
|
||||
const postResponse = await fetch(`/api/posts/${params.id}`);
|
||||
if (!postResponse.ok) {
|
||||
throw new Error('Failed to fetch post');
|
||||
}
|
||||
const postData = await postResponse.json();
|
||||
|
||||
// Set form values
|
||||
setTitle(postData.title);
|
||||
|
||||
// Try to parse the content as JSON, or use it as a simple string if parsing fails
|
||||
try {
|
||||
setContent(JSON.parse(postData.content));
|
||||
} catch (e) {
|
||||
// If content is not valid JSON, create a simple paragraph block
|
||||
setContent({
|
||||
time: new Date().getTime(),
|
||||
blocks: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: postData.content
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
setParentId(postData.parentId);
|
||||
setUserId(postData.userId);
|
||||
|
||||
// Fetch all posts for parent selection (excluding the current post)
|
||||
const postsResponse = await fetch('/api/posts');
|
||||
const postsData = await postsResponse.json();
|
||||
setPosts(postsData.filter((post: Post) => post.id !== params.id));
|
||||
|
||||
// Fetch users for author selection
|
||||
const usersResponse = await fetch('/api/users');
|
||||
const usersData = await usersResponse.json();
|
||||
setUsers(usersData);
|
||||
} catch (err) {
|
||||
console.error('Error fetching data:', err);
|
||||
setError('Failed to load data. Please try again.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [params.id]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!title || !content) {
|
||||
setError('Please fill in all required fields');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/posts/${params.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
content: JSON.stringify(content),
|
||||
parentId: parentId || null,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to update post');
|
||||
}
|
||||
|
||||
router.push('/admin/posts');
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
console.error('Error updating post:', err);
|
||||
setError(err instanceof Error ? err.message : 'An unknown error occurred');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="text-gray-500">Loading post data...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Edit Post</h1>
|
||||
<Link
|
||||
href="/admin/posts"
|
||||
className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-4 text-red-700 bg-red-100 rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="title" className="block text-sm font-medium text-gray-700">
|
||||
Title <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm text-black px-4 py-2"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="content" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Content <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Editor
|
||||
data={content}
|
||||
onChange={setContent}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="parent" className="block text-sm font-medium text-gray-700">
|
||||
Parent Post
|
||||
</label>
|
||||
<select
|
||||
id="parent"
|
||||
value={parentId || ''}
|
||||
onChange={(e) => setParentId(e.target.value || null)}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm text-black px-4 py-2"
|
||||
>
|
||||
<option value="">None (Root Post)</option>
|
||||
{posts.map((post) => (
|
||||
<option key={post.id} value={post.id}>
|
||||
{post.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Author
|
||||
</label>
|
||||
<div className="mt-1 block w-full rounded-md border border-gray-300 bg-gray-50 px-4 py-2 text-gray-500">
|
||||
{users.find(user => user.id === userId)?.username || 'Unknown'}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500">Author cannot be changed after creation</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3">
|
||||
<Link
|
||||
href="/admin/posts"
|
||||
className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</Link>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:bg-indigo-300"
|
||||
>
|
||||
{isSubmitting ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
187
src/app/(admin)/admin/posts/new/page.tsx
Normal file
187
src/app/(admin)/admin/posts/new/page.tsx
Normal file
@ -0,0 +1,187 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Editor from '@/lib/components/EditorJS';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface Post {
|
||||
id: string;
|
||||
title: string;
|
||||
parentId: string | null;
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export default function NewPost() {
|
||||
const router = useRouter();
|
||||
const [title, setTitle] = useState('');
|
||||
const [content, setContent] = useState<any>({});
|
||||
const [parentId, setParentId] = useState<string | null>(null);
|
||||
const [posts, setPosts] = useState<Post[]>([]);
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Fetch posts and current user
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
// Fetch posts for parent selection
|
||||
const postsResponse = await fetch('/api/posts');
|
||||
const postsData = await postsResponse.json();
|
||||
setPosts(postsData);
|
||||
|
||||
// Fetch current user
|
||||
const authResponse = await fetch('/api/auth');
|
||||
const authData = await authResponse.json();
|
||||
|
||||
if (authData.authenticated) {
|
||||
setCurrentUser(authData.user);
|
||||
} else {
|
||||
setError('You must be logged in to create a post');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching data:', err);
|
||||
setError('Failed to load data. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!title || !content) {
|
||||
setError('Please fill in all required fields');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentUser) {
|
||||
setError('You must be logged in to create a post');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/posts', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
content: JSON.stringify(content),
|
||||
userId: currentUser.id,
|
||||
parentId: parentId || null,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to create post');
|
||||
}
|
||||
|
||||
router.push('/admin/posts');
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
console.error('Error creating post:', err);
|
||||
setError(err instanceof Error ? err.message : 'An unknown error occurred');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Create New Post</h1>
|
||||
<Link
|
||||
href="/admin/posts"
|
||||
className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-4 text-red-700 bg-red-100 rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="title" className="block text-sm font-medium text-gray-700">
|
||||
Title <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm text-black px-4 py-2"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="content" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Content <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Editor
|
||||
data={content}
|
||||
onChange={setContent}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="parent" className="block text-sm font-medium text-gray-700">
|
||||
Parent Post
|
||||
</label>
|
||||
<select
|
||||
id="parent"
|
||||
value={parentId || ''}
|
||||
onChange={(e) => setParentId(e.target.value || null)}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm text-black px-4 py-2"
|
||||
>
|
||||
<option value="">None (Root Post)</option>
|
||||
{posts.map((post) => (
|
||||
<option key={post.id} value={post.id}>
|
||||
{post.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{currentUser && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Author
|
||||
</label>
|
||||
<div className="mt-1 block w-full rounded-md border border-gray-300 bg-gray-50 px-4 py-2 text-gray-500">
|
||||
{currentUser.username}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500">Posts are created with your current user account</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:bg-indigo-300"
|
||||
>
|
||||
{isSubmitting ? 'Creating...' : 'Create Post'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
104
src/app/(admin)/admin/posts/page.tsx
Normal file
104
src/app/(admin)/admin/posts/page.tsx
Normal file
@ -0,0 +1,104 @@
|
||||
import Link from 'next/link';
|
||||
import { Post, getDataSource } from '@/lib/database';
|
||||
import DeleteButton from './DeleteButton';
|
||||
|
||||
export default async function AdminPosts() {
|
||||
// Fetch posts from the database
|
||||
const dataSource = await getDataSource();
|
||||
const postRepository = dataSource.getRepository(Post);
|
||||
const posts = await postRepository.find({
|
||||
relations: ['user'],
|
||||
order: { createdAt: 'DESC' }
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Posts</h1>
|
||||
<Link
|
||||
href="/admin/posts/new"
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
Add New Post
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex flex-col">
|
||||
<div className="-my-2 -mx-4 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||
<div className="inline-block min-w-full py-2 align-middle md:px-6 lg:px-8">
|
||||
<div className="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
|
||||
<table className="min-w-full divide-y divide-gray-300">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
className="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6"
|
||||
>
|
||||
Title
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
|
||||
>
|
||||
Author
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
|
||||
>
|
||||
Created
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
|
||||
>
|
||||
Modified
|
||||
</th>
|
||||
<th scope="col" className="relative py-3.5 pl-3 pr-4 sm:pr-6">
|
||||
<span className="sr-only">Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 bg-white">
|
||||
{posts.length > 0 ? (
|
||||
posts.map((post: Post) => (
|
||||
<tr key={post.id}>
|
||||
<td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6">
|
||||
{post.title}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
{post.user?.username || 'Unknown'}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
{new Date(post.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
{new Date(post.modifiedAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
|
||||
<Link
|
||||
href={`/admin/posts/edit/${post.id}`}
|
||||
className="text-indigo-600 hover:text-indigo-900 mr-4"
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
<DeleteButton postId={post.id} />
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={5} className="py-4 pl-4 pr-3 text-sm text-gray-500 text-center">
|
||||
No posts found. Create your first post!
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
src/app/(admin)/admin/profile/page.tsx
Normal file
11
src/app/(admin)/admin/profile/page.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import ProfileEditor from '@/lib/components/ProfileEditor';
|
||||
|
||||
export default function AdminProfilePage() {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<ProfileEditor isAdmin={true} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
src/app/(admin)/admin/users/DeleteButton.tsx
Normal file
57
src/app/(admin)/admin/users/DeleteButton.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
interface DeleteButtonProps {
|
||||
userId: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export default function DeleteButton({ userId, username }: DeleteButtonProps) {
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm(`Are you sure you want to delete user "${username}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDeleting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/users/${userId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to delete user');
|
||||
}
|
||||
|
||||
// Refresh the page to show updated user list
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="text-red-600 hover:text-red-900 disabled:opacity-50"
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
{error && (
|
||||
<div className="text-red-500 text-xs mt-1">{error}</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
234
src/app/(admin)/admin/users/components/EditUser.tsx
Normal file
234
src/app/(admin)/admin/users/components/EditUser.tsx
Normal file
@ -0,0 +1,234 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, FormEvent, ChangeEvent } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
createdAt: string;
|
||||
modifiedAt: string;
|
||||
}
|
||||
|
||||
interface EditUserProps {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export default function EditUser({ id }: EditUserProps) {
|
||||
const router = useRouter();
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
password: '', // Optional for updates
|
||||
});
|
||||
const [avatar, setAvatar] = useState<File | null>(null);
|
||||
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Fetch user data
|
||||
useEffect(() => {
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/users/${id}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch user');
|
||||
}
|
||||
|
||||
const user: User = await response.json();
|
||||
|
||||
setFormData({
|
||||
username: user.username,
|
||||
password: '', // Don't populate password
|
||||
});
|
||||
|
||||
if (user.avatar) {
|
||||
setAvatarPreview(user.avatar);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
} catch (err) {
|
||||
setError('Failed to load user data');
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUser();
|
||||
}, [id]);
|
||||
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleAvatarChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
const file = e.target.files[0];
|
||||
setAvatar(file);
|
||||
|
||||
// Create a preview URL
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setAvatarPreview(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Validate form
|
||||
if (!formData.username) {
|
||||
throw new Error('Username is required');
|
||||
}
|
||||
|
||||
// Create form data for submission
|
||||
const submitData = new FormData();
|
||||
submitData.append('username', formData.username);
|
||||
|
||||
// Only include password if it was changed
|
||||
if (formData.password) {
|
||||
submitData.append('password', formData.password);
|
||||
}
|
||||
|
||||
// Only include avatar if a new one was selected
|
||||
if (avatar) {
|
||||
submitData.append('avatar', avatar);
|
||||
}
|
||||
|
||||
// Submit the form
|
||||
const response = await fetch(`/api/users/${id}`, {
|
||||
method: 'PUT',
|
||||
body: submitData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to update user');
|
||||
}
|
||||
|
||||
// Redirect to users list on success
|
||||
router.push('/admin/users');
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-center py-10">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Edit User</h1>
|
||||
<Link
|
||||
href="/admin/users"
|
||||
className="text-indigo-600 hover:text-indigo-900"
|
||||
>
|
||||
Back to Users
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border-l-4 border-red-400 p-4 mb-6">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4">
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="username">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
id="username"
|
||||
type="text"
|
||||
name="username"
|
||||
value={formData.username}
|
||||
onChange={handleChange}
|
||||
placeholder="Username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="password">
|
||||
Password (leave blank to keep current password)
|
||||
</label>
|
||||
<input
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
id="password"
|
||||
type="password"
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
placeholder="New password (optional)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="avatar">
|
||||
Avatar
|
||||
</label>
|
||||
<input
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
id="avatar"
|
||||
type="file"
|
||||
name="avatar"
|
||||
onChange={handleAvatarChange}
|
||||
accept="image/*"
|
||||
/>
|
||||
|
||||
{avatarPreview && (
|
||||
<div className="mt-2">
|
||||
<Image
|
||||
src={avatarPreview}
|
||||
alt="Avatar preview"
|
||||
width={80}
|
||||
height={80}
|
||||
className="rounded-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
className="bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline disabled:opacity-50"
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Updating...' : 'Update User'}
|
||||
</button>
|
||||
<Link
|
||||
href="/admin/users"
|
||||
className="inline-block align-baseline font-bold text-sm text-indigo-600 hover:text-indigo-800"
|
||||
>
|
||||
Cancel
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
src/app/(admin)/admin/users/edit/[id]/page.tsx
Normal file
6
src/app/(admin)/admin/users/edit/[id]/page.tsx
Normal file
@ -0,0 +1,6 @@
|
||||
import EditUser from '../../components/EditUser';
|
||||
|
||||
export default async function EditUserPage(props: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await props.params;
|
||||
return <EditUser id={id} />;
|
||||
}
|
||||
182
src/app/(admin)/admin/users/new/page.tsx
Normal file
182
src/app/(admin)/admin/users/new/page.tsx
Normal file
@ -0,0 +1,182 @@
|
||||
'use client';
|
||||
|
||||
import { useState, FormEvent, ChangeEvent } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
|
||||
export default function NewUser() {
|
||||
const router = useRouter();
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
password: '',
|
||||
});
|
||||
const [avatar, setAvatar] = useState<File | null>(null);
|
||||
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleAvatarChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
const file = e.target.files[0];
|
||||
setAvatar(file);
|
||||
|
||||
// Create a preview URL
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setAvatarPreview(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Validate form
|
||||
if (!formData.username || !formData.password) {
|
||||
throw new Error('Username and password are required');
|
||||
}
|
||||
|
||||
// Create form data for submission
|
||||
const submitData = new FormData();
|
||||
submitData.append('username', formData.username);
|
||||
submitData.append('password', formData.password);
|
||||
|
||||
if (avatar) {
|
||||
submitData.append('avatar', avatar);
|
||||
}
|
||||
|
||||
// Submit the form
|
||||
const response = await fetch('/api/users', {
|
||||
method: 'POST',
|
||||
body: submitData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to create user');
|
||||
}
|
||||
|
||||
// Redirect to users list on success
|
||||
router.push('/admin/users');
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Add New User</h1>
|
||||
<Link
|
||||
href="/admin/users"
|
||||
className="text-indigo-600 hover:text-indigo-900"
|
||||
>
|
||||
Back to Users
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border-l-4 border-red-400 p-4 mb-6">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4">
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="username">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
id="username"
|
||||
type="text"
|
||||
name="username"
|
||||
value={formData.username}
|
||||
onChange={handleChange}
|
||||
placeholder="Username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="password">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
id="password"
|
||||
type="password"
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
placeholder="Password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="avatar">
|
||||
Avatar
|
||||
</label>
|
||||
<input
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
id="avatar"
|
||||
type="file"
|
||||
name="avatar"
|
||||
onChange={handleAvatarChange}
|
||||
accept="image/*"
|
||||
/>
|
||||
|
||||
{avatarPreview && (
|
||||
<div className="mt-2">
|
||||
<Image
|
||||
src={avatarPreview}
|
||||
alt="Avatar preview"
|
||||
width={80}
|
||||
height={80}
|
||||
className="rounded-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
className="bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline disabled:opacity-50"
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Creating...' : 'Create User'}
|
||||
</button>
|
||||
<Link
|
||||
href="/admin/users"
|
||||
className="inline-block align-baseline font-bold text-sm text-indigo-600 hover:text-indigo-800"
|
||||
>
|
||||
Cancel
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
118
src/app/(admin)/admin/users/page.tsx
Normal file
118
src/app/(admin)/admin/users/page.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { getDataSource, User } from '@/lib/database';
|
||||
import DeleteButton from './DeleteButton';
|
||||
|
||||
export default async function AdminUsers() {
|
||||
// Fetch users from the database
|
||||
const dataSource = await getDataSource();
|
||||
const userRepository = dataSource.getRepository(User);
|
||||
|
||||
const users = await userRepository.find({
|
||||
select: ['id', 'username', 'avatar', 'createdAt', 'modifiedAt'],
|
||||
order: { createdAt: 'DESC' }
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Users</h1>
|
||||
<Link
|
||||
href="/admin/users/new"
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
Add New User
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex flex-col">
|
||||
<div className="-my-2 -mx-4 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||
<div className="inline-block min-w-full py-2 align-middle md:px-6 lg:px-8">
|
||||
<div className="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
|
||||
<table className="min-w-full divide-y divide-gray-300">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
className="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6"
|
||||
>
|
||||
Username
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
|
||||
>
|
||||
Avatar
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
|
||||
>
|
||||
Created
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
|
||||
>
|
||||
Modified
|
||||
</th>
|
||||
<th scope="col" className="relative py-3.5 pl-3 pr-4 sm:pr-6">
|
||||
<span className="sr-only">Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 bg-white">
|
||||
{users.length > 0 ? (
|
||||
users.map((user) => (
|
||||
<tr key={user.id}>
|
||||
<td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6">
|
||||
{user.username}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
{user.avatar ? (
|
||||
<Image
|
||||
src={user.avatar}
|
||||
alt={user.username}
|
||||
width={40}
|
||||
height={40}
|
||||
className="rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-10 w-10 rounded-full bg-gray-200 flex items-center justify-center text-gray-500">
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
{new Date(user.modifiedAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
|
||||
<Link
|
||||
href={`/admin/users/edit/${user.id}`}
|
||||
className="text-indigo-600 hover:text-indigo-900 mr-4"
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
<DeleteButton userId={user.id} username={user.username} />
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={5} className="py-4 pl-4 pr-3 text-sm text-gray-500 text-center">
|
||||
No users found. Create your first user!
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
src/app/(front)/layout.tsx
Normal file
42
src/app/(front)/layout.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "../globals.css";
|
||||
import DatabaseInitializer from "@/lib/components/DatabaseInitializer";
|
||||
import { ThemeProvider } from "@/lib/context/ThemeContext";
|
||||
import ThemeToggleWrapper from "@/lib/components/ThemeToggleWrapper";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "KantanCMS",
|
||||
description: "A simple CMS with admin console and frontpage",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{/* Initialize database connection */}
|
||||
<DatabaseInitializer />
|
||||
<ThemeProvider>
|
||||
<ThemeToggleWrapper />
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
102
src/app/(front)/page.tsx
Normal file
102
src/app/(front)/page.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
import Link from 'next/link';
|
||||
import { Post, getDataSource } from '@/lib/database';
|
||||
import FrontendHeader from '@/lib/components/FrontendHeader';
|
||||
import PostSidebar from '@/lib/components/PostSidebar';
|
||||
import EditorJSRenderer from '@/lib/components/EditorJSRenderer';
|
||||
|
||||
export default async function Home() {
|
||||
// Fetch posts from the database
|
||||
const dataSource = await getDataSource();
|
||||
const postRepository = dataSource.getRepository(Post);
|
||||
const posts = await postRepository.find({
|
||||
relations: ['user'],
|
||||
order: { createdAt: 'DESC' },
|
||||
take: 10 // Limit to 10 most recent posts
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<FrontendHeader />
|
||||
<main>
|
||||
<div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||
<div className="px-4 py-6 sm:px-0">
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
{/* Sidebar */}
|
||||
<div className="md:w-1/4">
|
||||
<PostSidebar />
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="md:w-3/4 border-4 border-dashed border-gray-200 rounded-lg p-4 min-h-96">
|
||||
<h2 className="text-2xl font-bold text-black mb-6">Latest Posts</h2>
|
||||
|
||||
{posts.length > 0 ? (
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{posts.map((post) => (
|
||||
<div key={post.id} className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 truncate">
|
||||
{post.title}
|
||||
</h3>
|
||||
<div className="mt-2 text-sm text-gray-500 line-clamp-3 overflow-hidden max-h-16">
|
||||
{/* Try to extract a preview from the EditorJS content */}
|
||||
{(() => {
|
||||
try {
|
||||
const content = JSON.parse(post.content);
|
||||
if (content.blocks && content.blocks.length > 0) {
|
||||
// Get the first block's text content
|
||||
const firstBlock = content.blocks[0];
|
||||
if (firstBlock.type === 'paragraph' && firstBlock.data.text) {
|
||||
return firstBlock.data.text.substring(0, 150) + (firstBlock.data.text.length > 150 ? '...' : '');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// If parsing fails, just show the first 150 characters
|
||||
}
|
||||
return post.content.substring(0, 150) + (post.content.length > 150 ? '...' : '');
|
||||
})()}
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<Link
|
||||
href={`/posts/${post.id}`}
|
||||
className="text-indigo-600 hover:text-indigo-900 text-sm font-medium"
|
||||
>
|
||||
Read more →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 px-4 py-4 sm:px-6">
|
||||
<div className="text-sm text-gray-500">
|
||||
Posted on {new Date(post.createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 mb-4">No posts found.</p>
|
||||
<p className="text-gray-500">
|
||||
Visit the{' '}
|
||||
<Link href="/admin" className="text-indigo-600 hover:text-indigo-900">
|
||||
admin panel
|
||||
</Link>{' '}
|
||||
to create your first post.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<footer className="bg-white">
|
||||
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
||||
<p className="text-center text-gray-500 text-sm">
|
||||
© {new Date().getFullYear()} KantanCMS. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
108
src/app/(front)/posts/[id]/page.tsx
Normal file
108
src/app/(front)/posts/[id]/page.tsx
Normal file
@ -0,0 +1,108 @@
|
||||
import Link from 'next/link';
|
||||
import { Post, getDataSource } from '@/lib/database';
|
||||
import FrontendHeader from '@/lib/components/FrontendHeader';
|
||||
import PostSidebar from '@/lib/components/PostSidebar';
|
||||
import EditorJSRenderer from '@/lib/components/EditorJSRenderer';
|
||||
|
||||
interface PostDetailProps {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Generate static params for all posts
|
||||
export async function generateStaticParams() {
|
||||
const dataSource = await getDataSource();
|
||||
const postRepository = dataSource.getRepository(Post);
|
||||
const posts = await postRepository.find();
|
||||
|
||||
return posts.map((post) => ({
|
||||
id: post.id,
|
||||
}));
|
||||
}
|
||||
|
||||
export default async function PostDetail({ params }: PostDetailProps) {
|
||||
// Fetch the post from the database
|
||||
const dataSource = await getDataSource();
|
||||
const postRepository = dataSource.getRepository(Post);
|
||||
const post = await postRepository.findOne({
|
||||
where: { id: params.id },
|
||||
relations: ['user', 'parent']
|
||||
});
|
||||
|
||||
if (!post) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col">
|
||||
<FrontendHeader />
|
||||
<main className="flex-grow">
|
||||
<div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||
<div className="px-4 py-6 sm:px-0">
|
||||
<div className="text-center py-12">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">Post Not Found</h2>
|
||||
<p className="text-gray-500 mb-4">The post you are looking for does not exist.</p>
|
||||
<Link href="/" className="text-indigo-600 hover:text-indigo-900">
|
||||
Return to homepage
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<footer className="bg-white">
|
||||
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
||||
<p className="text-center text-gray-500 text-sm">
|
||||
© {new Date().getFullYear()} KantanCMS. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col">
|
||||
<FrontendHeader />
|
||||
<main className="flex-grow">
|
||||
<div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||
<div className="px-4 py-6 sm:px-0">
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
{/* Sidebar */}
|
||||
<div className="md:w-1/4">
|
||||
<PostSidebar />
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="md:w-3/4">
|
||||
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
|
||||
<div className="px-4 py-5 sm:px-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900">{post.title}</h2>
|
||||
<p className="mt-1 max-w-2xl text-sm text-gray-500">
|
||||
Posted on {new Date(post.createdAt).toLocaleDateString()}
|
||||
{post.user && ` by ${post.user.username}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="border-t border-gray-200">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<EditorJSRenderer data={post.content} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-gray-200 px-4 py-4 sm:px-6">
|
||||
<Link href="/" className="text-indigo-600 hover:text-indigo-900">
|
||||
← Back to all posts
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<footer className="bg-white mt-auto">
|
||||
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
||||
<p className="text-center text-gray-500 text-sm">
|
||||
© {new Date().getFullYear()} KantanCMS. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
src/app/(front)/profile/page.tsx
Normal file
38
src/app/(front)/profile/page.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import ProfileEditor from '@/lib/components/ProfileEditor';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function FrontendProfilePage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<header className="bg-white shadow">
|
||||
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8 flex justify-between items-center">
|
||||
<h1 className="text-3xl font-bold text-gray-900">KantanCMS</h1>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700"
|
||||
>
|
||||
Back to Home
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||
<div className="px-4 py-6 sm:px-0">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<ProfileEditor isAdmin={false} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<footer className="bg-white">
|
||||
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
||||
<p className="text-center text-gray-500 text-sm">
|
||||
© {new Date().getFullYear()} KantanCMS. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
124
src/app/api/auth/route.ts
Normal file
124
src/app/api/auth/route.ts
Normal file
@ -0,0 +1,124 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getDataSource, User } from '@/lib/database';
|
||||
|
||||
// POST /api/auth - Login
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const data = await request.json();
|
||||
const { username, password } = data;
|
||||
|
||||
// Validate required fields
|
||||
if (!username || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Username and password are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get user from database
|
||||
const dataSource = await getDataSource();
|
||||
const userRepository = dataSource.getRepository(User);
|
||||
const user = await userRepository.findOne({ where: { username } });
|
||||
|
||||
// Check if user exists
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid username or password' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate password
|
||||
const isPasswordValid = await user.validatePassword(password);
|
||||
if (!isPasswordValid) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid username or password' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Return success without password
|
||||
const { password: _, ...userWithoutPassword } = user;
|
||||
|
||||
// Create response with authentication cookie
|
||||
const response = NextResponse.json(userWithoutPassword);
|
||||
response.cookies.set({
|
||||
name: 'auth',
|
||||
value: user.id,
|
||||
httpOnly: true,
|
||||
path: '/',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 60 * 60 * 24 * 7, // 1 week
|
||||
sameSite: 'strict',
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Authentication failed' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/auth - Logout
|
||||
export async function DELETE() {
|
||||
try {
|
||||
// Create response and clear authentication cookie
|
||||
const response = NextResponse.json({ success: true });
|
||||
response.cookies.delete('auth');
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Logout failed' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/auth - Check if user is authenticated
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Get auth cookie from request
|
||||
const authCookie = request.cookies.get('auth');
|
||||
|
||||
if (!authCookie?.value) {
|
||||
return NextResponse.json(
|
||||
{ authenticated: false },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get user from database
|
||||
const dataSource = await getDataSource();
|
||||
const userRepository = dataSource.getRepository(User);
|
||||
const user = await userRepository.findOne({
|
||||
where: { id: authCookie.value },
|
||||
select: ['id', 'username', 'avatar', 'theme', 'createdAt', 'modifiedAt']
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
// Create response and clear invalid auth cookie
|
||||
const response = NextResponse.json(
|
||||
{ authenticated: false },
|
||||
{ status: 401 }
|
||||
);
|
||||
response.cookies.delete('auth');
|
||||
return response;
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
authenticated: true,
|
||||
user
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Auth check error:', error);
|
||||
return NextResponse.json(
|
||||
{ authenticated: false },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
113
src/app/api/posts/[id]/route.ts
Normal file
113
src/app/api/posts/[id]/route.ts
Normal file
@ -0,0 +1,113 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getDataSource, Post } from '@/lib/database';
|
||||
|
||||
// GET /api/posts/[id] - Get a specific post
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const id = params.id;
|
||||
const dataSource = await getDataSource();
|
||||
const postRepository = dataSource.getRepository(Post);
|
||||
|
||||
const post = await postRepository.findOne({
|
||||
where: { id },
|
||||
relations: ['user', 'parent']
|
||||
});
|
||||
|
||||
if (!post) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Post not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(post);
|
||||
} catch (error) {
|
||||
console.error('Error fetching post:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch post' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/posts/[id] - Update a post
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const id = params.id;
|
||||
const data = await request.json();
|
||||
const { title, content, parentId } = data;
|
||||
|
||||
// Validate required fields
|
||||
if (!title || !content) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Title and content are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const dataSource = await getDataSource();
|
||||
const postRepository = dataSource.getRepository(Post);
|
||||
|
||||
// Check if post exists
|
||||
const post = await postRepository.findOneBy({ id });
|
||||
if (!post) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Post not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Update post
|
||||
post.title = title;
|
||||
post.content = content;
|
||||
post.parentId = parentId || null;
|
||||
|
||||
await postRepository.save(post);
|
||||
|
||||
return NextResponse.json(post);
|
||||
} catch (error) {
|
||||
console.error('Error updating post:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update post' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/posts/[id] - Delete a post
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const id = params.id;
|
||||
const dataSource = await getDataSource();
|
||||
const postRepository = dataSource.getRepository(Post);
|
||||
|
||||
// Check if post exists
|
||||
const post = await postRepository.findOneBy({ id });
|
||||
if (!post) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Post not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Delete post
|
||||
await postRepository.remove(post);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error deleting post:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete post' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
78
src/app/api/posts/route.ts
Normal file
78
src/app/api/posts/route.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getDataSource, Post } from '@/lib/database';
|
||||
|
||||
// GET /api/posts - Get all posts
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const dataSource = await getDataSource();
|
||||
const postRepository = dataSource.getRepository(Post);
|
||||
|
||||
// Get query parameters
|
||||
const url = new URL(request.url);
|
||||
const parentId = url.searchParams.get('parentId');
|
||||
|
||||
// Build query
|
||||
let query = postRepository.createQueryBuilder('post')
|
||||
.leftJoinAndSelect('post.user', 'user')
|
||||
.leftJoinAndSelect('post.parent', 'parent')
|
||||
.orderBy('post.createdAt', 'DESC');
|
||||
|
||||
// Filter by parentId if provided
|
||||
if (parentId) {
|
||||
if (parentId === 'null') {
|
||||
// Get root posts (no parent)
|
||||
query = query.where('post.parentId IS NULL');
|
||||
} else {
|
||||
// Get children of specific parent
|
||||
query = query.where('post.parentId = :parentId', { parentId });
|
||||
}
|
||||
}
|
||||
|
||||
const posts = await query.getMany();
|
||||
|
||||
return NextResponse.json(posts);
|
||||
} catch (error) {
|
||||
console.error('Error fetching posts:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch posts' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/posts - Create a new post
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const data = await request.json();
|
||||
const { title, content, userId, parentId } = data;
|
||||
|
||||
// Validate required fields
|
||||
if (!title || !content || !userId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Title, content, and userId are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const dataSource = await getDataSource();
|
||||
const postRepository = dataSource.getRepository(Post);
|
||||
|
||||
// Create new post
|
||||
const newPost = postRepository.create({
|
||||
title,
|
||||
content,
|
||||
userId,
|
||||
parentId: parentId || null
|
||||
});
|
||||
|
||||
await postRepository.save(newPost);
|
||||
|
||||
return NextResponse.json(newPost, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Error creating post:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create post' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
76
src/app/api/upload/route.ts
Normal file
76
src/app/api/upload/route.ts
Normal file
@ -0,0 +1,76 @@
|
||||
'use server';
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { writeFile } from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { existsSync, mkdirSync } from 'fs';
|
||||
|
||||
// Ensure uploads directory exists
|
||||
const ensureUploadsDir = () => {
|
||||
const uploadsDir = path.join(process.cwd(), 'public/uploads');
|
||||
if (!existsSync(uploadsDir)) {
|
||||
mkdirSync(uploadsDir, { recursive: true });
|
||||
}
|
||||
return uploadsDir;
|
||||
};
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('image') as File;
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No file uploaded' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid file type. Only JPEG, PNG, GIF and WebP are allowed.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get file extension
|
||||
const fileExt = file.name.split('.').pop() || '';
|
||||
|
||||
// Create a unique filename
|
||||
const timestamp = Date.now();
|
||||
const randomId = Math.random().toString(36).substring(2, 15);
|
||||
const filename = `${timestamp}-${randomId}.${fileExt}`;
|
||||
|
||||
// Ensure uploads directory exists
|
||||
const uploadsDir = ensureUploadsDir();
|
||||
const filepath = path.join(uploadsDir, filename);
|
||||
|
||||
// Convert file to buffer and save it
|
||||
const bytes = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(bytes);
|
||||
|
||||
await writeFile(filepath, buffer);
|
||||
|
||||
// Return the URL to the uploaded file
|
||||
const fileUrl = `/uploads/${filename}`;
|
||||
|
||||
return NextResponse.json({
|
||||
success: 1,
|
||||
file: {
|
||||
url: fileUrl,
|
||||
// You can add more metadata if needed
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error uploading file:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to upload file' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
179
src/app/api/users/[id]/route.ts
Normal file
179
src/app/api/users/[id]/route.ts
Normal file
@ -0,0 +1,179 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getDataSource, User } from '@/lib/database';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// GET /api/users/[id] - Get a specific user
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
props: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await props.params;
|
||||
const dataSource = await getDataSource();
|
||||
const userRepository = dataSource.getRepository(User);
|
||||
|
||||
const user = await userRepository.findOne({
|
||||
where: { id: id },
|
||||
select: ['id', 'username', 'avatar', 'theme', 'createdAt', 'modifiedAt']
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'User not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(user);
|
||||
} catch (error) {
|
||||
console.error('Error fetching user:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch user' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/users/[id] - Update a user
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
props: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await props.params;
|
||||
const dataSource = await getDataSource();
|
||||
const userRepository = dataSource.getRepository(User);
|
||||
|
||||
// Find the user to update
|
||||
const user = await userRepository.findOne({
|
||||
where: { id: id }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'User not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const username = formData.get('username') as string;
|
||||
const password = formData.get('password') as string | null;
|
||||
const theme = formData.get('theme') as string | null;
|
||||
|
||||
// Update username if provided
|
||||
if (username && username !== user.username) {
|
||||
// Check if the new username already exists
|
||||
const existingUser = await userRepository.findOne({ where: { username } });
|
||||
if (existingUser && existingUser.id !== user.id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Username already exists' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
user.username = username;
|
||||
}
|
||||
|
||||
// Update password if provided
|
||||
if (password) {
|
||||
user.password = password;
|
||||
await user.hashPassword();
|
||||
}
|
||||
|
||||
// Update theme if provided
|
||||
if (theme) {
|
||||
user.theme = theme;
|
||||
}
|
||||
|
||||
// Handle avatar upload if provided
|
||||
const avatarFile = formData.get('avatar') as File;
|
||||
|
||||
if (avatarFile && avatarFile.size > 0) {
|
||||
// Delete old avatar file if it exists
|
||||
if (user.avatar) {
|
||||
const oldAvatarPath = path.join(process.cwd(), 'public', user.avatar);
|
||||
if (fs.existsSync(oldAvatarPath)) {
|
||||
fs.unlinkSync(oldAvatarPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Create a unique filename
|
||||
const fileExtension = avatarFile.name.split('.').pop();
|
||||
const fileName = `${Date.now()}-${Math.random().toString(36).substring(2, 15)}.${fileExtension}`;
|
||||
|
||||
// Save the file to the public directory
|
||||
const avatarBuffer = await avatarFile.arrayBuffer();
|
||||
|
||||
// Create uploads directory if it doesn't exist
|
||||
const uploadDir = path.join(process.cwd(), 'public', 'uploads');
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Write the file
|
||||
fs.writeFileSync(path.join(uploadDir, fileName), Buffer.from(avatarBuffer));
|
||||
|
||||
// Set the avatar path to be stored in the database
|
||||
user.avatar = `/uploads/${fileName}`;
|
||||
}
|
||||
|
||||
// Save the updated user
|
||||
const updatedUser = await userRepository.save(user);
|
||||
|
||||
// Return the user without the password
|
||||
const { password: _, ...userWithoutPassword } = updatedUser;
|
||||
|
||||
return NextResponse.json(userWithoutPassword);
|
||||
} catch (error) {
|
||||
console.error('Error updating user:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update user' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/users/[id] - Delete a user
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
props: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await props.params;
|
||||
const dataSource = await getDataSource();
|
||||
const userRepository = dataSource.getRepository(User);
|
||||
|
||||
// Find the user to delete
|
||||
const user = await userRepository.findOne({
|
||||
where: { id: id }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'User not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Delete avatar file if it exists
|
||||
if (user.avatar) {
|
||||
const avatarPath = path.join(process.cwd(), 'public', user.avatar);
|
||||
if (fs.existsSync(avatarPath)) {
|
||||
fs.unlinkSync(avatarPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the user
|
||||
await userRepository.remove(user);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error deleting user:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete user' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
101
src/app/api/users/route.ts
Normal file
101
src/app/api/users/route.ts
Normal file
@ -0,0 +1,101 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getDataSource, User } from '@/lib/database';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// GET /api/users - Get all users
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const dataSource = await getDataSource();
|
||||
const userRepository = dataSource.getRepository(User);
|
||||
|
||||
const users = await userRepository.find({
|
||||
select: ['id', 'username', 'avatar', 'createdAt', 'modifiedAt'],
|
||||
order: { createdAt: 'DESC' }
|
||||
});
|
||||
|
||||
return NextResponse.json(users);
|
||||
} catch (error) {
|
||||
console.error('Error fetching users:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch users' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/users - Create a new user
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const dataSource = await getDataSource();
|
||||
const userRepository = dataSource.getRepository(User);
|
||||
|
||||
const formData = await request.formData();
|
||||
const username = formData.get('username') as string;
|
||||
const password = formData.get('password') as string;
|
||||
|
||||
// Validate required fields
|
||||
if (!username || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Username and password are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if username already exists
|
||||
const existingUser = await userRepository.findOne({ where: { username } });
|
||||
if (existingUser) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Username already exists' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Handle avatar upload if provided
|
||||
let avatarPath: string | null = null;
|
||||
const avatarFile = formData.get('avatar') as File;
|
||||
|
||||
if (avatarFile && avatarFile.size > 0) {
|
||||
// Create a unique filename
|
||||
const fileExtension = avatarFile.name.split('.').pop();
|
||||
const fileName = `${Date.now()}-${Math.random().toString(36).substring(2, 15)}.${fileExtension}`;
|
||||
|
||||
// Save the file to the public directory
|
||||
const avatarBuffer = await avatarFile.arrayBuffer();
|
||||
|
||||
// Create uploads directory if it doesn't exist
|
||||
const uploadDir = path.join(process.cwd(), 'public', 'uploads');
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Write the file
|
||||
fs.writeFileSync(path.join(uploadDir, fileName), Buffer.from(avatarBuffer));
|
||||
|
||||
// Set the avatar path to be stored in the database
|
||||
avatarPath = `/uploads/${fileName}`;
|
||||
}
|
||||
|
||||
// Create and save the new user
|
||||
const user = new User();
|
||||
user.username = username;
|
||||
user.password = password;
|
||||
user.avatar = avatarPath;
|
||||
|
||||
// Hash the password before saving
|
||||
await user.hashPassword();
|
||||
|
||||
const savedUser = await userRepository.save(user);
|
||||
|
||||
// Return the user without the password
|
||||
const { password: _, ...userWithoutPassword } = savedUser;
|
||||
|
||||
return NextResponse.json(userWithoutPassword, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Error creating user:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create user' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
BIN
src/app/favicon.ico
Normal file
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
139
src/app/globals.css
Normal file
139
src/app/globals.css
Normal file
@ -0,0 +1,139 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not(.light):not(.dark) {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
.codex-editor,
|
||||
.ce-block__content,
|
||||
.ce-toolbar__content {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.codex-editor {
|
||||
margin-left: 50px;
|
||||
}
|
||||
|
||||
/* Dark mode styles */
|
||||
.dark .bg-white {
|
||||
background-color: #1a1a1a;
|
||||
}
|
||||
|
||||
.dark .bg-gray-50,
|
||||
.dark .bg-gray-100 {
|
||||
background-color: #111111;
|
||||
}
|
||||
|
||||
.dark .text-gray-900 {
|
||||
color: #f5f5f5;
|
||||
}
|
||||
|
||||
.dark .text-gray-800 {
|
||||
color: #e5e5e5;
|
||||
}
|
||||
|
||||
.dark .text-gray-700 {
|
||||
color: #d5d5d5;
|
||||
}
|
||||
|
||||
.dark .text-gray-600 {
|
||||
color: #c5c5c5;
|
||||
}
|
||||
|
||||
.dark .text-gray-500 {
|
||||
color: #a5a5a5;
|
||||
}
|
||||
|
||||
.dark .border-gray-300,
|
||||
.dark .border-gray-200 {
|
||||
border-color: #333333;
|
||||
}
|
||||
|
||||
.dark .shadow,
|
||||
.dark .shadow-md,
|
||||
.dark .shadow-sm {
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.3), 0 1px 2px 0 rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.dark .hover\:bg-gray-100:hover {
|
||||
background-color: #222222;
|
||||
}
|
||||
|
||||
.dark .hover\:text-gray-700:hover {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* Fix text colors in dark mode */
|
||||
.dark .text-black {
|
||||
color: #f5f5f5;
|
||||
}
|
||||
|
||||
/* Fix for treeview in front page */
|
||||
.dark .text-sm.text-black {
|
||||
color: #f5f5f5;
|
||||
}
|
||||
|
||||
/* Fix for "Latest Posts" header */
|
||||
.dark h2.text-2xl.font-bold.text-black {
|
||||
color: #f5f5f5;
|
||||
}
|
||||
|
||||
/* Fix for Post form content in admin */
|
||||
.dark .ce-paragraph {
|
||||
color: #f5f5f5;
|
||||
}
|
||||
|
||||
.dark .ce-block--selected .ce-block__content,
|
||||
.dark .ce-inline-toolbar,
|
||||
.dark .codex-editor--narrow .ce-toolbox,
|
||||
.dark .ce-conversion-toolbar,
|
||||
.dark .ce-settings,
|
||||
.dark .ce-settings__button,
|
||||
.dark .ce-toolbar__settings-btn,
|
||||
.dark .cdx-button,
|
||||
.dark .ce-toolbar__plus {
|
||||
background: #2a2a2a;
|
||||
color: #f5f5f5;
|
||||
border-color: #444;
|
||||
}
|
||||
|
||||
.dark .ce-inline-tool,
|
||||
.dark .ce-conversion-toolbar__label,
|
||||
.dark .ce-toolbox__button,
|
||||
.dark .cdx-settings-button,
|
||||
.dark .ce-toolbar__plus svg {
|
||||
color: #f5f5f5;
|
||||
}
|
||||
|
||||
/* Fix for Post form title in admin */
|
||||
.dark input[type="text"],
|
||||
.dark textarea {
|
||||
color: #f5f5f5;
|
||||
background-color: #1a1a1a;
|
||||
}
|
||||
13
src/lib/components/DatabaseInitializer.tsx
Normal file
13
src/lib/components/DatabaseInitializer.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
214
src/lib/components/EditorJS/index.tsx
Normal file
214
src/lib/components/EditorJS/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
167
src/lib/components/EditorJSRenderer/index.tsx
Normal file
167
src/lib/components/EditorJSRenderer/index.tsx
Normal 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;
|
||||
57
src/lib/components/FrontendHeader/index.tsx
Normal file
57
src/lib/components/FrontendHeader/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
91
src/lib/components/PostSidebar/index.tsx
Normal file
91
src/lib/components/PostSidebar/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
285
src/lib/components/ProfileEditor/index.tsx
Normal file
285
src/lib/components/ProfileEditor/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
src/lib/components/ThemeToggle/index.tsx
Normal file
59
src/lib/components/ThemeToggle/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
src/lib/components/ThemeToggleWrapper.tsx
Normal file
11
src/lib/components/ThemeToggleWrapper.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
135
src/lib/components/UserMenu/index.tsx
Normal file
135
src/lib/components/UserMenu/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
81
src/lib/context/ThemeContext.tsx
Normal file
81
src/lib/context/ThemeContext.tsx
Normal 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;
|
||||
}
|
||||
57
src/lib/database/config.ts
Normal file
57
src/lib/database/config.ts
Normal 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());
|
||||
35
src/lib/database/entities/Post.ts
Normal file
35
src/lib/database/entities/Post.ts
Normal 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;
|
||||
}
|
||||
41
src/lib/database/entities/User.ts
Normal file
41
src/lib/database/entities/User.ts
Normal 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
36
src/lib/database/index.ts
Normal 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
65
src/middleware.ts
Normal 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).*)',
|
||||
],
|
||||
};
|
||||
42
src/scripts/create-test-user-with-theme.ts
Normal file
42
src/scripts/create-test-user-with-theme.ts
Normal 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();
|
||||
37
src/scripts/create-test-user.ts
Normal file
37
src/scripts/create-test-user.ts
Normal 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();
|
||||
91
src/scripts/reset-database.ts
Normal file
91
src/scripts/reset-database.ts
Normal 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
10
src/types/editorjs.d.ts
vendored
Normal 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
43
tsconfig.json
Normal 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
11
tsconfig.scripts.json
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user