feat : Initial commit
This commit is contained in:
231
nest-intra/src/pages/intranet/IntranetModeration.tsx
Normal file
231
nest-intra/src/pages/intranet/IntranetModeration.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { MOCK_THREADS, MOCK_REPLIES } from '../../data/mockData';
|
||||
import { formatDateTime } from '../../utils/format';
|
||||
import type { ForumThread, ForumReply } from '../../types';
|
||||
|
||||
export default function IntranetModeration() {
|
||||
const [threads, setThreads] = useState<ForumThread[]>(MOCK_THREADS);
|
||||
const [replies, setReplies] = useState<ForumReply[]>(MOCK_REPLIES);
|
||||
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [activeTab, setActiveTab] = useState<'threads' | 'replies'>('threads');
|
||||
|
||||
const filteredThreads = useMemo(() => {
|
||||
if (!search.trim()) return threads;
|
||||
const q = search.toLowerCase();
|
||||
return threads.filter((t) => t.title.toLowerCase().includes(q) || t.authorName.toLowerCase().includes(q));
|
||||
}, [threads, search]);
|
||||
|
||||
const selectedThreadReplies = useMemo(() => {
|
||||
if (!selectedThreadId) return [];
|
||||
return replies.filter((r) => r.threadId === selectedThreadId);
|
||||
}, [replies, selectedThreadId]);
|
||||
|
||||
const deleteThread = useCallback((id: string) => {
|
||||
setThreads((prev) => prev.filter((t) => t.id !== id));
|
||||
setReplies((prev) => prev.filter((r) => r.threadId !== id));
|
||||
if (selectedThreadId === id) setSelectedThreadId(null);
|
||||
}, [selectedThreadId]);
|
||||
|
||||
const togglePin = useCallback((id: string) => {
|
||||
setThreads((prev) => prev.map((t) => t.id === id ? { ...t, isPinned: !t.isPinned } : t));
|
||||
}, []);
|
||||
|
||||
const toggleLock = useCallback((id: string) => {
|
||||
setThreads((prev) => prev.map((t) => t.id === id ? { ...t, isLocked: !t.isLocked } : t));
|
||||
}, []);
|
||||
|
||||
const deleteReply = useCallback((id: string) => {
|
||||
setReplies((prev) => prev.filter((r) => r.id !== id));
|
||||
}, []);
|
||||
|
||||
const recentReplies = useMemo(() => {
|
||||
return [...replies].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()).slice(0, 20);
|
||||
}, [replies]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: '1.75rem' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em', marginBottom: '0.5rem' }}>
|
||||
INTRANET / MODERATION
|
||||
</div>
|
||||
<h1 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)', fontSize: '1.8rem', marginBottom: '0.5rem' }}>FORUM MODERATION</h1>
|
||||
<p style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.78rem' }}>
|
||||
{threads.length} threads — {replies.length} replies
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{ display: 'flex', borderBottom: '1px solid var(--color-border)', marginBottom: '1.5rem', gap: 0 }}>
|
||||
{(['threads', 'replies'] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderBottom: activeTab === tab ? '2px solid var(--color-amber)' : '2px solid transparent',
|
||||
color: activeTab === tab ? 'var(--color-amber)' : 'var(--color-text-muted)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.75rem',
|
||||
padding: '0.55rem 1rem',
|
||||
cursor: 'pointer',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
}}
|
||||
>
|
||||
{tab === 'threads' ? `Threads (${threads.length})` : `Replies (${replies.length})`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === 'threads' && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: selectedThreadId ? '1fr 340px' : '1fr', gap: '1.25rem', alignItems: 'start' }}>
|
||||
{/* Thread list */}
|
||||
<div>
|
||||
<input
|
||||
className="input-terminal"
|
||||
type="search"
|
||||
placeholder="Search threads..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
style={{ marginBottom: '1rem', maxWidth: '300px' }}
|
||||
/>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
|
||||
{filteredThreads.map((thread) => (
|
||||
<div
|
||||
key={thread.id}
|
||||
style={{
|
||||
background: selectedThreadId === thread.id ? 'rgba(37,99,235,0.08)' : 'var(--color-surface)',
|
||||
border: `1px solid ${selectedThreadId === thread.id ? 'var(--color-yellow)' : 'var(--color-border)'}`,
|
||||
padding: '0.85rem 1.1rem',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '0.5rem', flexWrap: 'wrap', marginBottom: '0.3rem' }}>
|
||||
<div>
|
||||
<div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap', marginBottom: '0.2rem' }}>
|
||||
{thread.isPinned && <span className="badge badge-progress">Pinned</span>}
|
||||
{thread.isLocked && <span className="badge badge-closed">Locked</span>}
|
||||
</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text)', fontSize: '0.83rem' }}>{thread.title}</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.68rem', marginTop: '0.2rem' }}>
|
||||
by {thread.authorName} — {thread.categoryName} — {thread.replyCount} replies
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.4rem', marginTop: '0.6rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
className="btn-terminal"
|
||||
style={{ padding: '0.15rem 0.55rem', fontSize: '0.65rem' }}
|
||||
onClick={() => setSelectedThreadId(selectedThreadId === thread.id ? null : thread.id)}
|
||||
>
|
||||
Replies
|
||||
</button>
|
||||
<button
|
||||
className={`btn-terminal ${thread.isPinned ? 'btn-danger' : 'btn-amber'}`}
|
||||
style={{ padding: '0.15rem 0.55rem', fontSize: '0.65rem' }}
|
||||
onClick={() => togglePin(thread.id)}
|
||||
>
|
||||
{thread.isPinned ? 'Unpin' : 'Pin'}
|
||||
</button>
|
||||
<button
|
||||
className="btn-terminal btn-amber"
|
||||
style={{ padding: '0.15rem 0.55rem', fontSize: '0.65rem' }}
|
||||
onClick={() => toggleLock(thread.id)}
|
||||
>
|
||||
{thread.isLocked ? 'Unlock' : 'Lock'}
|
||||
</button>
|
||||
<button
|
||||
className="btn-terminal btn-danger"
|
||||
style={{ padding: '0.15rem 0.55rem', fontSize: '0.65rem' }}
|
||||
onClick={() => deleteThread(thread.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{filteredThreads.length === 0 && (
|
||||
<div style={{ padding: '2rem', textAlign: 'center', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.8rem', background: 'var(--color-surface)', border: '1px solid var(--color-border)' }}>
|
||||
No threads found.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Thread replies panel */}
|
||||
{selectedThreadId && (
|
||||
<div style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', padding: '1.25rem', position: 'sticky', top: '1rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-amber)', fontSize: '0.7rem', letterSpacing: '0.1em' }}>
|
||||
REPLIES ({selectedThreadReplies.length})
|
||||
</div>
|
||||
<button onClick={() => setSelectedThreadId(null)} style={{ background: 'transparent', border: 'none', color: 'var(--color-text-muted)', cursor: 'pointer' }} aria-label="Close">✕</button>
|
||||
</div>
|
||||
{selectedThreadReplies.length === 0 ? (
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.78rem' }}>No replies.</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
{selectedThreadReplies.map((reply) => (
|
||||
<div key={reply.id} style={{ background: 'var(--color-surface-alt)', border: '1px solid var(--color-border)', padding: '0.75rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.35rem' }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-dim)', fontSize: '0.72rem' }}>{reply.authorName}</span>
|
||||
<button
|
||||
className="btn-terminal btn-danger"
|
||||
style={{ padding: '0.1rem 0.45rem', fontSize: '0.6rem' }}
|
||||
onClick={() => deleteReply(reply.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.77rem', lineHeight: 1.65 }}>
|
||||
{reply.content.slice(0, 150)}{reply.content.length > 150 ? '...' : ''}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'replies' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
|
||||
{recentReplies.map((reply) => {
|
||||
const thread = threads.find((t) => t.id === reply.threadId);
|
||||
return (
|
||||
<div key={reply.id} style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', padding: '0.85rem 1.1rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.68rem', marginBottom: '0.2rem' }}>
|
||||
by <span style={{ color: 'var(--color-text-dim)' }}>{reply.authorName}</span>
|
||||
{thread && <> in <span style={{ color: 'var(--color-text-dim)' }}>{thread.title}</span></>}
|
||||
{' '}— {formatDateTime(reply.createdAt)}
|
||||
</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text)', fontSize: '0.82rem', lineHeight: 1.6, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{reply.content}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="btn-terminal btn-danger"
|
||||
style={{ padding: '0.15rem 0.55rem', fontSize: '0.65rem', flexShrink: 0 }}
|
||||
onClick={() => deleteReply(reply.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{recentReplies.length === 0 && (
|
||||
<div style={{ padding: '2rem', textAlign: 'center', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.8rem', background: 'var(--color-surface)', border: '1px solid var(--color-border)' }}>
|
||||
No replies found.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user