initial commit
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user