Skip to content

Commit 6c1acb7

Browse files
authored
Merge pull request #45 from shreyannandanwar/admin-dashboard
Admin dashboard
2 parents 007787a + 6680560 commit 6c1acb7

42 files changed

Lines changed: 4930 additions & 1853 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
1-
# Gemini AI API Configuration
2-
# Get your API key from: https://makersuite.google.com/app/apikey
3-
NEXT_PUBLIC_GEMINI_API_KEY=your_gemini_api_key_here
1+
# Example env vars; copy to .env.local or set in deployment
42

5-
# MongoDB Database Configuration
6-
# Format: mongodb+srv://username:password@cluster.mongodb.net/database?retryWrites=true&w=majority
7-
MONGODB_URI=mongodb+srv://username:password@cluster.mongodb.net/toolbox?retryWrites=true&w=majority
3+
# Core
4+
MONGODB_URI=mongodb://127.0.0.1:27017/toolbox
5+
NEXTAUTH_URL=http://localhost:3000
6+
NEXTAUTH_SECRET=change-me
87

9-
# Rate Limiting (Upstash Redis)
10-
UPSTASH_REDIS_REST_URL=your-upstash-redis-url
11-
UPSTASH_REDIS_REST_TOKEN=your-upstash-redis-token
8+
# Dev admin (Credentials provider)
9+
ADMIN_EMAIL=admin@local
10+
ADMIN_PASSWORD=admin123
11+
12+
# Feature flags
13+
ENABLE_SPAM_CHECK=false
14+
15+
# Optional providers
16+
# UPSTASH_REDIS_REST_URL=
17+
# UPSTASH_REDIS_REST_TOKEN=
18+
# AKISMET_API_KEY=
19+
# AKISMET_SITE_URL=

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
# production
1616
/build
1717

18+
# backups
19+
/backups/
20+
1821
# misc
1922
.DS_Store
2023
*.pem

app/admin/announcements/page.tsx

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
'use client';
2+
3+
import { useEffect, useState } from 'react';
4+
import { Form, Input, Button, message, Table, Switch, DatePicker } from 'antd';
5+
6+
interface AnnouncementRow {
7+
_id: string;
8+
title: string;
9+
message: string;
10+
published?: boolean;
11+
startsAt?: string;
12+
endsAt?: string;
13+
createdAt?: string;
14+
}
15+
16+
export default function AdminAnnouncementsPage() {
17+
const [form] = Form.useForm();
18+
const [rows, setRows] = useState<AnnouncementRow[]>([]);
19+
const [loading, setLoading] = useState(false);
20+
21+
const fetchAnnouncements = async () => {
22+
const res = await fetch('/api/admin/announcements');
23+
if (res.ok) {
24+
const data = await res.json();
25+
setRows(data);
26+
}
27+
};
28+
29+
useEffect(() => {
30+
fetchAnnouncements();
31+
}, []);
32+
33+
const onFinish = async (values: { title: string; message: string; published?: boolean; startsAt?: any; endsAt?: any }) => {
34+
setLoading(true);
35+
const payload = {
36+
...values,
37+
startsAt: values.startsAt ? values.startsAt.toISOString?.() : undefined,
38+
endsAt: values.endsAt ? values.endsAt.toISOString?.() : undefined,
39+
};
40+
const res = await fetch('/api/admin/announcements', {
41+
method: 'POST',
42+
headers: { 'Content-Type': 'application/json' },
43+
body: JSON.stringify(payload),
44+
});
45+
setLoading(false);
46+
if (res.ok) {
47+
message.success('Announcement created');
48+
form.resetFields();
49+
fetchAnnouncements();
50+
} else {
51+
const err = await res.json().catch(() => ({}));
52+
message.error(err.error || 'Failed to create announcement');
53+
}
54+
};
55+
56+
const columns = [
57+
{ title: 'Title', dataIndex: 'title' },
58+
{ title: 'Message', dataIndex: 'message' },
59+
{ title: 'Published', dataIndex: 'published', render: (v: boolean) => (v ? 'Yes' : 'No') },
60+
{ title: 'Starts', dataIndex: 'startsAt' },
61+
{ title: 'Ends', dataIndex: 'endsAt' },
62+
{ title: 'Created', dataIndex: 'createdAt' },
63+
];
64+
65+
return (
66+
<div>
67+
<h2>Announcements</h2>
68+
<Form form={form} layout="vertical" onFinish={onFinish} style={{ maxWidth: 640 }} initialValues={{ published: true }}>
69+
<Form.Item name="title" label="Title" rules={[{ required: true }]}>
70+
<Input />
71+
</Form.Item>
72+
<Form.Item name="message" label="Message" rules={[{ required: true }]}>
73+
<Input.TextArea rows={4} />
74+
</Form.Item>
75+
<Form.Item name="published" label="Published" valuePropName="checked">
76+
<Switch />
77+
</Form.Item>
78+
<Form.Item name="startsAt" label="Starts">
79+
<DatePicker showTime />
80+
</Form.Item>
81+
<Form.Item name="endsAt" label="Ends">
82+
<DatePicker showTime />
83+
</Form.Item>
84+
<Form.Item>
85+
<Button type="primary" htmlType="submit" loading={loading}>
86+
Create Announcement
87+
</Button>
88+
</Form.Item>
89+
</Form>
90+
91+
<Table style={{ marginTop: 24 }} dataSource={rows} columns={columns as any} rowKey="_id" />
92+
</div>
93+
);
94+
}

app/admin/backup/page.tsx

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
'use client';
2+
3+
import { useEffect, useState } from 'react';
4+
import { Button, Input, Space, message, Table } from 'antd';
5+
6+
interface BackupFile { name: string; mtime?: string; size?: number }
7+
8+
export default function AdminBackupPage() {
9+
const [collection, setCollection] = useState('toolbox');
10+
const [backupFile, setBackupFile] = useState('');
11+
const [files, setFiles] = useState<BackupFile[]>([]);
12+
const [loadingList, setLoadingList] = useState(false);
13+
const [busy, setBusy] = useState(false);
14+
15+
const fetchList = async () => {
16+
setLoadingList(true);
17+
try {
18+
const res = await fetch('/api/admin/backup-list');
19+
if (res.ok) {
20+
const data = await res.json();
21+
setFiles(data.files || []);
22+
}
23+
} finally {
24+
setLoadingList(false);
25+
}
26+
};
27+
28+
useEffect(() => {
29+
fetchList();
30+
}, []);
31+
32+
const handleBackup = async () => {
33+
if (!collection) return message.error('Enter collection');
34+
setBusy(true);
35+
try {
36+
const res = await fetch(`/api/admin/backup?collection=${encodeURIComponent(collection)}`, { method: 'POST' });
37+
const data = await res.json().catch(() => ({}));
38+
if (res.ok) {
39+
message.success('Backup created');
40+
setBackupFile(data.file);
41+
fetchList();
42+
} else {
43+
message.error(data.error || 'Failed to create backup');
44+
}
45+
} finally {
46+
setBusy(false);
47+
}
48+
};
49+
50+
const handleRestore = async () => {
51+
if (!collection || !backupFile) return message.error('Enter collection and file');
52+
setBusy(true);
53+
try {
54+
const res = await fetch(`/api/admin/restore?collection=${encodeURIComponent(collection)}&file=${encodeURIComponent(backupFile)}`, { method: 'POST' });
55+
const data = await res.json().catch(() => ({}));
56+
if (res.ok) {
57+
message.success('Restore completed');
58+
} else {
59+
message.error(data.error || 'Failed to restore');
60+
}
61+
} finally {
62+
setBusy(false);
63+
}
64+
};
65+
66+
return (
67+
<div>
68+
<h2>Backup & Restore</h2>
69+
<Space direction="vertical" size="middle" style={{ display: 'flex', maxWidth: 700 }}>
70+
<div>
71+
<div style={{ marginBottom: 8 }}>Collection</div>
72+
<Input placeholder="collection" value={collection} onChange={(e) => setCollection(e.target.value)} style={{ maxWidth: 300 }} />
73+
</div>
74+
<Space>
75+
<Button type="primary" onClick={handleBackup} loading={busy}>Create Backup</Button>
76+
<Input placeholder="backup file name" value={backupFile} onChange={(e) => setBackupFile(e.target.value)} style={{ width: 320 }} />
77+
<Button danger onClick={handleRestore} loading={busy}>Restore from Backup</Button>
78+
</Space>
79+
<div>
80+
<Space style={{ marginBottom: 8 }}>
81+
<strong>Backups</strong>
82+
<Button size="small" onClick={fetchList} loading={loadingList}>Refresh</Button>
83+
</Space>
84+
<Table
85+
size="small"
86+
dataSource={files}
87+
rowKey="name"
88+
columns={[
89+
{ title: 'File', dataIndex: 'name' },
90+
{ title: 'Modified', dataIndex: 'mtime' },
91+
{ title: 'Size (bytes)', dataIndex: 'size' },
92+
{ title: 'Action', render: (_: any, row: BackupFile) => (
93+
<Button onClick={() => setBackupFile(row.name)}>Use</Button>
94+
)},
95+
] as any}
96+
/>
97+
</div>
98+
</Space>
99+
</div>
100+
);
101+
}

app/admin/content/page.tsx

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
'use client';
2+
3+
import { useEffect, useMemo, useState } from 'react';
4+
import { Table, Button, Popconfirm, Switch, Tag, message, Select, Input, Space } from 'antd';
5+
6+
interface ContentRow {
7+
_id: string;
8+
title: string;
9+
body?: string;
10+
userId?: string;
11+
status: 'draft' | 'pending' | 'approved' | 'rejected';
12+
featured?: boolean;
13+
createdAt?: string;
14+
}
15+
16+
export default function AdminContentPage() {
17+
const [rows, setRows] = useState<ContentRow[]>([]);
18+
const [loading, setLoading] = useState(true);
19+
const [status, setStatus] = useState<'all' | 'pending' | 'approved' | 'rejected'>('pending');
20+
const [q, setQ] = useState('');
21+
const [onlyFeatured, setOnlyFeatured] = useState(false);
22+
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
23+
24+
const fetchQueue = async () => {
25+
setLoading(true);
26+
const params = new URLSearchParams();
27+
if (status) params.set('status', status);
28+
if (q) params.set('q', q);
29+
if (onlyFeatured) params.set('featured', 'true');
30+
const res = await fetch(`/api/admin/content?${params.toString()}`);
31+
if (!res.ok) {
32+
message.error('Failed to load content');
33+
setRows([]);
34+
setLoading(false);
35+
return;
36+
}
37+
const data = await res.json();
38+
setRows(data);
39+
setLoading(false);
40+
};
41+
42+
useEffect(() => {
43+
fetchQueue();
44+
// eslint-disable-next-line react-hooks/exhaustive-deps
45+
}, [status, q, onlyFeatured]);
46+
47+
const approve = async (row: ContentRow) => {
48+
const res = await fetch(`/api/admin/content?id=${row._id}`, {
49+
method: 'PUT',
50+
headers: { 'Content-Type': 'application/json' },
51+
body: JSON.stringify({ action: 'approve' }),
52+
});
53+
if (res.ok) {
54+
message.success('Approved');
55+
fetchQueue();
56+
} else {
57+
message.error('Failed to approve');
58+
}
59+
};
60+
61+
const reject = async (row: ContentRow) => {
62+
const res = await fetch(`/api/admin/content?id=${row._id}`, {
63+
method: 'PUT',
64+
headers: { 'Content-Type': 'application/json' },
65+
body: JSON.stringify({ action: 'reject' }),
66+
});
67+
if (res.ok) {
68+
message.success('Rejected');
69+
fetchQueue();
70+
} else {
71+
message.error('Failed to reject');
72+
}
73+
};
74+
75+
const bulkAction = async (action: 'approve' | 'reject') => {
76+
if (selectedRowKeys.length === 0) return;
77+
const res = await fetch('/api/admin/content', {
78+
method: 'PUT',
79+
headers: { 'Content-Type': 'application/json' },
80+
body: JSON.stringify({ action, ids: selectedRowKeys }),
81+
});
82+
if (res.ok) {
83+
message.success(`Bulk ${action} complete`);
84+
setSelectedRowKeys([]);
85+
fetchQueue();
86+
} else {
87+
message.error(`Bulk ${action} failed`);
88+
}
89+
};
90+
91+
const toggleFeatured = async (row: ContentRow, featured: boolean) => {
92+
const res = await fetch(`/api/admin/content/${row._id}/featured`, {
93+
method: 'PUT',
94+
headers: { 'Content-Type': 'application/json' },
95+
body: JSON.stringify({ featured }),
96+
});
97+
if (res.ok) {
98+
message.success('Updated featured');
99+
fetchQueue();
100+
} else {
101+
message.error('Failed to update featured');
102+
}
103+
};
104+
105+
const columns = [
106+
{ title: 'Title', dataIndex: 'title' },
107+
{ title: 'Status', dataIndex: 'status', render: (s: string) => <Tag color={s === 'pending' ? 'orange' : s === 'approved' ? 'green' : 'red'}>{s}</Tag> },
108+
{ title: 'Featured', dataIndex: 'featured', render: (_: any, row: ContentRow) => (
109+
<Switch checked={!!row.featured} onChange={(checked) => toggleFeatured(row, checked)} />
110+
) },
111+
{ title: 'Actions', render: (_: any, row: ContentRow) => (
112+
<>
113+
<Button type="primary" onClick={() => approve(row)} style={{ marginRight: 8 }}>Approve</Button>
114+
<Popconfirm title="Reject this item?" onConfirm={() => reject(row)}>
115+
<Button danger>Reject</Button>
116+
</Popconfirm>
117+
</>
118+
) },
119+
];
120+
121+
const rowSelection = {
122+
selectedRowKeys,
123+
onChange: (keys: React.Key[]) => setSelectedRowKeys(keys),
124+
};
125+
126+
return (
127+
<div>
128+
<h2>Content Moderation</h2>
129+
<Space style={{ marginBottom: 12 }} wrap>
130+
<Select
131+
value={status}
132+
onChange={setStatus as any}
133+
options={[
134+
{ value: 'pending', label: 'Pending' },
135+
{ value: 'approved', label: 'Approved' },
136+
{ value: 'rejected', label: 'Rejected' },
137+
{ value: 'all', label: 'All' },
138+
]}
139+
style={{ width: 140 }}
140+
/>
141+
<Input.Search placeholder="Search title" allowClear value={q} onChange={(e) => setQ(e.target.value)} style={{ width: 220 }} />
142+
<Button type={onlyFeatured ? 'primary' : 'default'} onClick={() => setOnlyFeatured((v) => !v)}>
143+
{onlyFeatured ? 'Featured: On' : 'Featured: Off'}
144+
</Button>
145+
<Button onClick={() => bulkAction('approve')} disabled={selectedRowKeys.length === 0} type="primary">
146+
Bulk Approve
147+
</Button>
148+
<Button onClick={() => bulkAction('reject')} disabled={selectedRowKeys.length === 0} danger>
149+
Bulk Reject
150+
</Button>
151+
</Space>
152+
<Table
153+
loading={loading}
154+
dataSource={rows}
155+
columns={columns as any}
156+
rowKey="_id"
157+
rowSelection={rowSelection}
158+
/>
159+
</div>
160+
);
161+
}

0 commit comments

Comments
 (0)