initial commit

This commit is contained in:
Ken Yasue
2025-03-25 06:19:44 +01:00
parent b97fa96c25
commit 9aef2ad891
71 changed files with 13016 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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