Files
AutonetSellCar/frontend/src/app/board/[id]/page.tsx
AutonetSellCar Deploy e0c1f4540b feat: Add bulletin board system
- 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>
2026-01-10 01:34:41 +09:00

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>
);
}