717 lines
30 KiB
TypeScript
717 lines
30 KiB
TypeScript
import { useState, useMemo, useCallback, useEffect } from 'react';
|
|
import { formatDateTime } from '../../utils/format';
|
|
import { forumApi, settingsApi } from '../../utils/api';
|
|
import type { ForumCategory, ForumReply, ForumThread } from '../../types';
|
|
|
|
export default function IntranetModeration() {
|
|
const [categories, setCategories] = useState<ForumCategory[]>([]);
|
|
const [threads, setThreads] = useState<ForumThread[]>([]);
|
|
const [replies, setReplies] = useState<ForumReply[]>([]);
|
|
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null);
|
|
const [createTitle, setCreateTitle] = useState('');
|
|
const [createContent, setCreateContent] = useState('');
|
|
const [createCategoryId, setCreateCategoryId] = useState('');
|
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
|
const [isCategoryModalOpen, setIsCategoryModalOpen] = useState(false);
|
|
const [editingCategoryId, setEditingCategoryId] = useState<string | null>(null);
|
|
const [categoryName, setCategoryName] = useState('');
|
|
const [categoryDescription, setCategoryDescription] = useState('');
|
|
const [categoryIcon, setCategoryIcon] = useState('📁');
|
|
const [search, setSearch] = useState('');
|
|
const [activeTab, setActiveTab] = useState<'threads' | 'replies' | 'categories'>('threads');
|
|
const [isEnabled, setIsEnabled] = useState(true);
|
|
const [toggling, setToggling] = useState(false);
|
|
const [loading, setLoading] = useState(true);
|
|
const [creating, setCreating] = useState(false);
|
|
const [savingCategory, setSavingCategory] = useState(false);
|
|
const [error, setError] = useState('');
|
|
|
|
const loadModerationData = useCallback(async () => {
|
|
setLoading(true);
|
|
setError('');
|
|
|
|
try {
|
|
const [cats, threadRes] = await Promise.all([
|
|
forumApi.getCategories(),
|
|
forumApi.getThreads({ limit: 200 }),
|
|
]);
|
|
|
|
const loadedThreads = threadRes.data;
|
|
setCategories(cats);
|
|
setThreads(loadedThreads);
|
|
|
|
const detailed = await Promise.all(
|
|
loadedThreads.map((thread) => forumApi.getThread(thread.id).catch(() => null))
|
|
);
|
|
|
|
const allReplies = detailed
|
|
.filter((thread): thread is ForumThread & { replies: ForumReply[] } => Boolean(thread))
|
|
.flatMap((thread) => thread.replies);
|
|
setReplies(allReplies);
|
|
} catch {
|
|
setError('Failed to load moderation data.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
settingsApi.get().then((s) => setIsEnabled(s.forumEnabled)).catch(() => {});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void loadModerationData();
|
|
}, [loadModerationData]);
|
|
|
|
useEffect(() => {
|
|
if (categories.length === 0) {
|
|
setCreateCategoryId('');
|
|
return;
|
|
}
|
|
|
|
const exists = categories.some((category) => category.id === createCategoryId);
|
|
if (!exists) {
|
|
setCreateCategoryId(categories[0].id);
|
|
}
|
|
}, [categories, createCategoryId]);
|
|
|
|
const handleToggle = useCallback((enabled: boolean) => {
|
|
setToggling(true);
|
|
settingsApi.update({ forumEnabled: enabled })
|
|
.then(() => setIsEnabled(enabled))
|
|
.catch(() => {})
|
|
.finally(() => setToggling(false));
|
|
}, []);
|
|
|
|
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) => {
|
|
forumApi.deleteThread(id)
|
|
.then(() => {
|
|
setThreads((prev) => prev.filter((t) => t.id !== id));
|
|
setReplies((prev) => prev.filter((r) => r.threadId !== id));
|
|
if (selectedThreadId === id) setSelectedThreadId(null);
|
|
})
|
|
.catch(() => {
|
|
setError('Failed to delete thread.');
|
|
});
|
|
}, [selectedThreadId]);
|
|
|
|
const togglePin = useCallback((id: string) => {
|
|
const thread = threads.find((t) => t.id === id);
|
|
if (!thread) return;
|
|
|
|
forumApi.updateThread(id, { isPinned: !thread.isPinned })
|
|
.then((updated) => {
|
|
setThreads((prev) => prev.map((t) => (t.id === id ? updated : t)));
|
|
})
|
|
.catch(() => {
|
|
setError('Failed to update pin state.');
|
|
});
|
|
}, [threads]);
|
|
|
|
const toggleLock = useCallback((id: string) => {
|
|
const thread = threads.find((t) => t.id === id);
|
|
if (!thread) return;
|
|
|
|
forumApi.updateThread(id, { isLocked: !thread.isLocked })
|
|
.then((updated) => {
|
|
setThreads((prev) => prev.map((t) => (t.id === id ? updated : t)));
|
|
})
|
|
.catch(() => {
|
|
setError('Failed to update lock state.');
|
|
});
|
|
}, [threads]);
|
|
|
|
const deleteReply = useCallback((id: string) => {
|
|
const removedReply = replies.find((r) => r.id === id);
|
|
|
|
forumApi.deleteReply(id)
|
|
.then(() => {
|
|
setReplies((prev) => prev.filter((r) => r.id !== id));
|
|
setThreads((prev) => prev.map((t) => {
|
|
if (!removedReply || removedReply.threadId !== t.id) return t;
|
|
return { ...t, replyCount: Math.max(0, t.replyCount - 1) };
|
|
}));
|
|
})
|
|
.catch(() => {
|
|
setError('Failed to delete reply.');
|
|
});
|
|
}, [replies]);
|
|
|
|
const createThread = useCallback(() => {
|
|
const title = createTitle.trim();
|
|
const content = createContent.trim();
|
|
|
|
if (!title || !content || !createCategoryId) {
|
|
setError('Title, category and content are required.');
|
|
return;
|
|
}
|
|
|
|
setCreating(true);
|
|
setError('');
|
|
|
|
forumApi.createThread({
|
|
title,
|
|
content,
|
|
categoryId: createCategoryId,
|
|
})
|
|
.then((thread) => {
|
|
setThreads((prev) => [thread, ...prev]);
|
|
setCategories((prev) => prev.map((cat) => (
|
|
cat.id === createCategoryId
|
|
? { ...cat, threadCount: cat.threadCount + 1 }
|
|
: cat
|
|
)));
|
|
setCreateTitle('');
|
|
setCreateContent('');
|
|
setIsCreateModalOpen(false);
|
|
})
|
|
.catch(() => {
|
|
setError('Failed to create thread.');
|
|
})
|
|
.finally(() => {
|
|
setCreating(false);
|
|
});
|
|
}, [createCategoryId, createContent, createTitle]);
|
|
|
|
const openCreateCategoryModal = useCallback(() => {
|
|
setEditingCategoryId(null);
|
|
setCategoryName('');
|
|
setCategoryDescription('');
|
|
setCategoryIcon('📁');
|
|
setIsCategoryModalOpen(true);
|
|
}, []);
|
|
|
|
const openEditCategoryModal = useCallback((category: ForumCategory) => {
|
|
setEditingCategoryId(category.id);
|
|
setCategoryName(category.name);
|
|
setCategoryDescription(category.description);
|
|
setCategoryIcon(category.icon || '📁');
|
|
setIsCategoryModalOpen(true);
|
|
}, []);
|
|
|
|
const saveCategory = useCallback(() => {
|
|
const name = categoryName.trim();
|
|
const description = categoryDescription.trim();
|
|
const icon = categoryIcon.trim() || '📁';
|
|
|
|
if (!name || !description) {
|
|
setError('Category name and description are required.');
|
|
return;
|
|
}
|
|
|
|
setSavingCategory(true);
|
|
setError('');
|
|
|
|
const action = editingCategoryId
|
|
? forumApi.updateCategory(editingCategoryId, { name, description, icon })
|
|
: forumApi.createCategory({ name, description, icon });
|
|
|
|
action
|
|
.then(() => loadModerationData())
|
|
.then(() => setIsCategoryModalOpen(false))
|
|
.catch(() => {
|
|
setError(editingCategoryId ? 'Failed to update category.' : 'Failed to create category.');
|
|
})
|
|
.finally(() => {
|
|
setSavingCategory(false);
|
|
});
|
|
}, [categoryDescription, categoryIcon, categoryName, editingCategoryId, loadModerationData]);
|
|
|
|
const removeCategory = useCallback((id: string) => {
|
|
const confirmed = window.confirm('Delete this category? This can fail if it still has threads.');
|
|
if (!confirmed) return;
|
|
|
|
setError('');
|
|
forumApi.deleteCategory(id)
|
|
.then(() => loadModerationData())
|
|
.catch(() => {
|
|
setError('Failed to delete category. Remove or move threads first.');
|
|
});
|
|
}, [loadModerationData]);
|
|
|
|
const recentReplies = useMemo(() => {
|
|
return [...replies].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()).slice(0, 20);
|
|
}, [replies]);
|
|
|
|
return (
|
|
<div>
|
|
{!isEnabled ? (
|
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minHeight: '400px', textAlign: 'center' }}>
|
|
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em', marginBottom: '1rem' }}>
|
|
INTRANET / MODERATION
|
|
</div>
|
|
<h1 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-red)', fontSize: '2rem', marginBottom: '0.5rem' }}>FUNCTIONALITY DISABLED</h1>
|
|
<p style={{ color: 'var(--color-text-muted)', marginBottom: '1.5rem' }}>Forum Moderation feature is currently disabled</p>
|
|
<button
|
|
onClick={() => handleToggle(true)}
|
|
disabled={toggling}
|
|
style={{
|
|
background: 'var(--color-green)',
|
|
color: 'var(--color-bg)',
|
|
border: 'none',
|
|
padding: '0.6rem 1.2rem',
|
|
fontFamily: 'var(--font-mono)',
|
|
fontSize: '0.85rem',
|
|
cursor: toggling ? 'not-allowed' : 'pointer',
|
|
textTransform: 'uppercase',
|
|
letterSpacing: '0.08em',
|
|
opacity: toggling ? 0.6 : 1,
|
|
}}
|
|
>
|
|
Re-enable
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
{loading && (
|
|
<div style={{ marginBottom: '1rem', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.78rem' }}>
|
|
Loading moderation data...
|
|
</div>
|
|
)}
|
|
{error && (
|
|
<div style={{ marginBottom: '1rem', color: 'var(--color-red)', fontFamily: 'var(--font-mono)', fontSize: '0.78rem' }}>
|
|
{error}
|
|
</div>
|
|
)}
|
|
<div style={{ marginBottom: '1.75rem' }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
<div>
|
|
<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>
|
|
<button
|
|
onClick={() => handleToggle(false)}
|
|
disabled={toggling}
|
|
style={{
|
|
background: 'transparent',
|
|
border: '1px solid var(--color-red)',
|
|
color: 'var(--color-red)',
|
|
padding: '0.3rem 0.7rem',
|
|
fontFamily: 'var(--font-mono)',
|
|
fontSize: '0.65rem',
|
|
cursor: toggling ? 'not-allowed' : 'pointer',
|
|
textTransform: 'uppercase',
|
|
letterSpacing: '0.08em',
|
|
height: 'fit-content',
|
|
opacity: toggling ? 0.6 : 1,
|
|
}}
|
|
title="Disable this feature"
|
|
>
|
|
[DISABLE]
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div style={{ display: 'flex', borderBottom: '1px solid var(--color-border)', marginBottom: '1.5rem', gap: 0 }}>
|
|
{(['threads', 'replies', 'categories'] 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})`
|
|
: tab === 'replies'
|
|
? `Replies (${replies.length})`
|
|
: `Categories (${categories.length})`}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{activeTab === 'threads' && (
|
|
<div style={{ display: 'grid', gridTemplateColumns: selectedThreadId ? '1fr 340px' : '1fr', gap: '1.25rem', alignItems: 'start' }}>
|
|
{/* Thread list */}
|
|
<div>
|
|
<div style={{ marginBottom: '1rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
|
|
<button
|
|
className="btn-terminal btn-amber"
|
|
onClick={() => setIsCreateModalOpen(true)}
|
|
disabled={loading || categories.length === 0}
|
|
style={{ opacity: loading || categories.length === 0 ? 0.6 : 1 }}
|
|
>
|
|
+ Create Thread
|
|
</button>
|
|
{categories.length === 0 && (
|
|
<div style={{ color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.72rem' }}>
|
|
No categories available.
|
|
</div>
|
|
)}
|
|
</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)}
|
|
disabled={loading}
|
|
>
|
|
{thread.isPinned ? 'Unpin' : 'Pin'}
|
|
</button>
|
|
<button
|
|
className="btn-terminal btn-amber"
|
|
style={{ padding: '0.15rem 0.55rem', fontSize: '0.65rem' }}
|
|
onClick={() => toggleLock(thread.id)}
|
|
disabled={loading}
|
|
>
|
|
{thread.isLocked ? 'Unlock' : 'Lock'}
|
|
</button>
|
|
<button
|
|
className="btn-terminal btn-danger"
|
|
style={{ padding: '0.15rem 0.55rem', fontSize: '0.65rem' }}
|
|
onClick={() => deleteThread(thread.id)}
|
|
disabled={loading}
|
|
>
|
|
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>
|
|
)}
|
|
|
|
{activeTab === 'categories' && (
|
|
<div>
|
|
<div style={{ marginBottom: '1rem', display: 'flex', justifyContent: 'flex-end' }}>
|
|
<button className="btn-terminal btn-amber" onClick={openCreateCategoryModal}>
|
|
+ Create Category
|
|
</button>
|
|
</div>
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: '0.75rem' }}>
|
|
{categories.map((category) => (
|
|
<div
|
|
key={category.id}
|
|
style={{
|
|
background: 'var(--color-surface)',
|
|
border: '1px solid var(--color-border)',
|
|
padding: '1rem',
|
|
}}
|
|
>
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.4rem', gap: '0.5rem' }}>
|
|
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-amber)', fontSize: '0.9rem' }}>
|
|
{category.icon} {category.name}
|
|
</div>
|
|
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.68rem' }}>
|
|
{category.threadCount} threads
|
|
</span>
|
|
</div>
|
|
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.75rem', lineHeight: 1.6 }}>
|
|
{category.description}
|
|
</div>
|
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.4rem', marginTop: '0.7rem' }}>
|
|
<button
|
|
className="btn-terminal"
|
|
style={{ padding: '0.15rem 0.55rem', fontSize: '0.65rem' }}
|
|
onClick={() => openEditCategoryModal(category)}
|
|
>
|
|
Modify
|
|
</button>
|
|
<button
|
|
className="btn-terminal btn-danger"
|
|
style={{ padding: '0.15rem 0.55rem', fontSize: '0.65rem' }}
|
|
onClick={() => removeCategory(category.id)}
|
|
>
|
|
Remove
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{categories.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 categories found.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{isCreateModalOpen && (
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
style={{
|
|
position: 'fixed',
|
|
inset: 0,
|
|
background: 'rgba(0,0,0,0.55)',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
zIndex: 40,
|
|
padding: '1rem',
|
|
}}
|
|
>
|
|
<div style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', width: 'min(620px, 100%)', padding: '1rem' }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.8rem' }}>
|
|
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-amber)', fontSize: '0.75rem', letterSpacing: '0.1em' }}>
|
|
CREATE THREAD
|
|
</div>
|
|
<button
|
|
onClick={() => setIsCreateModalOpen(false)}
|
|
style={{ background: 'transparent', border: 'none', color: 'var(--color-text-muted)', cursor: 'pointer' }}
|
|
aria-label="Close create thread popup"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: '0.6rem' }}>
|
|
<input
|
|
className="input-terminal"
|
|
type="text"
|
|
placeholder="Thread title"
|
|
value={createTitle}
|
|
onChange={(e) => setCreateTitle(e.target.value)}
|
|
/>
|
|
<select
|
|
className="input-terminal"
|
|
value={createCategoryId}
|
|
onChange={(e) => setCreateCategoryId(e.target.value)}
|
|
>
|
|
{categories.map((category) => (
|
|
<option key={category.id} value={category.id}>{category.name}</option>
|
|
))}
|
|
</select>
|
|
<textarea
|
|
className="input-terminal"
|
|
rows={4}
|
|
placeholder="Thread content"
|
|
value={createContent}
|
|
onChange={(e) => setCreateContent(e.target.value)}
|
|
style={{ resize: 'vertical' }}
|
|
/>
|
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.5rem' }}>
|
|
<button className="btn-terminal" onClick={() => setIsCreateModalOpen(false)}>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
className="btn-terminal btn-amber"
|
|
onClick={createThread}
|
|
disabled={creating || loading || categories.length === 0}
|
|
style={{ opacity: creating || loading || categories.length === 0 ? 0.6 : 1 }}
|
|
>
|
|
{creating ? 'Creating...' : 'Create Thread'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{isCategoryModalOpen && (
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
style={{
|
|
position: 'fixed',
|
|
inset: 0,
|
|
background: 'rgba(0,0,0,0.55)',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
zIndex: 50,
|
|
padding: '1rem',
|
|
}}
|
|
>
|
|
<div style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', width: 'min(560px, 100%)', padding: '1rem' }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.8rem' }}>
|
|
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-amber)', fontSize: '0.75rem', letterSpacing: '0.1em' }}>
|
|
{editingCategoryId ? 'MODIFY CATEGORY' : 'CREATE CATEGORY'}
|
|
</div>
|
|
<button
|
|
onClick={() => setIsCategoryModalOpen(false)}
|
|
style={{ background: 'transparent', border: 'none', color: 'var(--color-text-muted)', cursor: 'pointer' }}
|
|
aria-label="Close category popup"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: '0.6rem' }}>
|
|
<input
|
|
className="input-terminal"
|
|
type="text"
|
|
placeholder="Category name"
|
|
value={categoryName}
|
|
onChange={(e) => setCategoryName(e.target.value)}
|
|
/>
|
|
<input
|
|
className="input-terminal"
|
|
type="text"
|
|
placeholder="Icon (emoji)"
|
|
value={categoryIcon}
|
|
onChange={(e) => setCategoryIcon(e.target.value)}
|
|
/>
|
|
<textarea
|
|
className="input-terminal"
|
|
rows={3}
|
|
placeholder="Category description"
|
|
value={categoryDescription}
|
|
onChange={(e) => setCategoryDescription(e.target.value)}
|
|
style={{ resize: 'vertical' }}
|
|
/>
|
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.5rem' }}>
|
|
<button className="btn-terminal" onClick={() => setIsCategoryModalOpen(false)}>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
className="btn-terminal btn-amber"
|
|
onClick={saveCategory}
|
|
disabled={savingCategory}
|
|
style={{ opacity: savingCategory ? 0.6 : 1 }}
|
|
>
|
|
{savingCategory ? 'Saving...' : editingCategoryId ? 'Save Changes' : 'Create Category'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|