- Add BoardCategory and BoardPost models with multi-language support - Add bulletin API endpoints (CRUD, notice toggle, pin toggle) - Add board_enabled setting to control menu visibility - Create frontend board pages (list, detail, write, edit) - Create admin board management and category management pages - Update Header.tsx with conditional Board menu between Inquiry and Contact Us - Update admin settings with board_enabled toggle - Add Board menu to admin sidebar Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
196 lines
7.6 KiB
TypeScript
196 lines
7.6 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { useRouter, useParams } from 'next/navigation';
|
|
import Link from 'next/link';
|
|
import { boardApi, BoardPost } from '@/lib/api';
|
|
import { useAuthStore } from '@/lib/store';
|
|
import { useTranslate } from '@/lib/useTranslate';
|
|
|
|
export default function BoardPostPage() {
|
|
const router = useRouter();
|
|
const params = useParams();
|
|
const postId = parseInt(params.id as string);
|
|
const { user, isLoggedIn } = useAuthStore();
|
|
const { translate, language } = useTranslate();
|
|
|
|
const [post, setPost] = useState<BoardPost | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [deleting, setDeleting] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const fetchPost = async () => {
|
|
try {
|
|
const data = await boardApi.getPost(postId);
|
|
setPost(data);
|
|
} catch (err: any) {
|
|
setError(err.response?.data?.detail || 'Failed to load post');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
fetchPost();
|
|
}, [postId]);
|
|
|
|
const getCategoryName = (post: BoardPost) => {
|
|
if (!post.category) return '-';
|
|
if (language === 'en' && post.category.name_en) return post.category.name_en;
|
|
if (language === 'mn' && post.category.name_mn) return post.category.name_mn;
|
|
if (language === 'ru' && post.category.name_ru) return post.category.name_ru;
|
|
return post.category.name;
|
|
};
|
|
|
|
const canEdit = isLoggedIn && post && (user?.id === post.author_id || user?.is_admin);
|
|
|
|
const handleDelete = async () => {
|
|
if (!confirm(translate('Are you sure you want to delete this post?'))) return;
|
|
|
|
setDeleting(true);
|
|
try {
|
|
await boardApi.deletePost(postId);
|
|
router.push('/board');
|
|
} catch (err: any) {
|
|
alert(err.response?.data?.detail || 'Failed to delete post');
|
|
} finally {
|
|
setDeleting(false);
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
|
<div className="inline-block animate-spin rounded-full h-8 w-8 border-4 border-blue-600 border-t-transparent"></div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error || !post) {
|
|
return (
|
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
|
<div className="text-center">
|
|
<p className="text-red-600 mb-4">{error || 'Post not found'}</p>
|
|
<Link href="/board" className="text-blue-600 hover:underline">
|
|
{translate('Back to list')}
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-50">
|
|
<div className="max-w-4xl mx-auto px-4 py-8">
|
|
{/* Back Button */}
|
|
<div className="mb-6">
|
|
<Link
|
|
href="/board"
|
|
className="inline-flex items-center text-gray-600 hover:text-gray-900"
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
|
</svg>
|
|
{translate('Back to list')}
|
|
</Link>
|
|
</div>
|
|
|
|
{/* Post */}
|
|
<article className="bg-white rounded-lg shadow-sm overflow-hidden">
|
|
{/* Header */}
|
|
<div className="p-6 border-b border-gray-200">
|
|
{/* Notice Badge */}
|
|
{post.is_notice && (
|
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 mb-3">
|
|
Notice
|
|
</span>
|
|
)}
|
|
|
|
{/* Title */}
|
|
<h1 className="text-2xl font-bold text-gray-900 mb-4">
|
|
{post.title}
|
|
</h1>
|
|
|
|
{/* Meta */}
|
|
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-500">
|
|
{/* Category */}
|
|
<span className="inline-flex items-center">
|
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
|
</svg>
|
|
{getCategoryName(post)}
|
|
</span>
|
|
|
|
{/* Author */}
|
|
<span className="inline-flex items-center">
|
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
|
</svg>
|
|
{post.author?.name || post.author?.email || 'Unknown'}
|
|
{post.author?.is_admin && (
|
|
<span className="ml-1 px-1.5 py-0.5 text-xs bg-blue-100 text-blue-800 rounded">Admin</span>
|
|
)}
|
|
</span>
|
|
|
|
{/* Date */}
|
|
<span className="inline-flex items-center">
|
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
|
</svg>
|
|
{new Date(post.created_at).toLocaleString()}
|
|
</span>
|
|
|
|
{/* Views */}
|
|
<span className="inline-flex items-center">
|
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
|
</svg>
|
|
{post.view_count} {translate('views')}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="p-6">
|
|
<div
|
|
className="prose prose-sm max-w-none text-gray-700"
|
|
style={{ whiteSpace: 'pre-wrap' }}
|
|
>
|
|
{post.content}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
{canEdit && (
|
|
<div className="px-6 py-4 bg-gray-50 border-t border-gray-200 flex justify-end gap-3">
|
|
<Link
|
|
href={`/board/edit/${post.id}`}
|
|
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
|
>
|
|
{translate('Edit')}
|
|
</Link>
|
|
<button
|
|
onClick={handleDelete}
|
|
disabled={deleting}
|
|
className="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50"
|
|
>
|
|
{deleting ? translate('Deleting...') : translate('Delete')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</article>
|
|
|
|
{/* Navigation Buttons */}
|
|
<div className="mt-6 flex justify-center">
|
|
<Link
|
|
href="/board"
|
|
className="px-6 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
|
>
|
|
{translate('List')}
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|