mern-template-tool 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +93 -0
- package/bin/index.js +112 -0
- package/package.json +33 -0
- package/templates/README.md +160 -0
- package/templates/_gitignore +8 -0
- package/templates/client/index.html +13 -0
- package/templates/client/package.json +20 -0
- package/templates/client/src/App.jsx +72 -0
- package/templates/client/src/components/Loading.jsx +14 -0
- package/templates/client/src/components/Navbar.jsx +58 -0
- package/templates/client/src/context/AuthContext.jsx +109 -0
- package/templates/client/src/index.css +244 -0
- package/templates/client/src/main.jsx +16 -0
- package/templates/client/src/pages/CreateResource.jsx +142 -0
- package/templates/client/src/pages/Dashboard.jsx +117 -0
- package/templates/client/src/pages/EditResource.jsx +166 -0
- package/templates/client/src/pages/Login.jsx +101 -0
- package/templates/client/src/pages/NotFound.jsx +21 -0
- package/templates/client/src/pages/Profile.jsx +52 -0
- package/templates/client/src/pages/Register.jsx +134 -0
- package/templates/client/src/pages/ResourceDetails.jsx +156 -0
- package/templates/client/src/pages/Resources.jsx +181 -0
- package/templates/client/src/routes/ProtectedRoute.jsx +34 -0
- package/templates/client/src/services/api.js +71 -0
- package/templates/client/vite.config.js +20 -0
- package/templates/server/.env.example +5 -0
- package/templates/server/config/db.js +13 -0
- package/templates/server/controllers/authController.js +131 -0
- package/templates/server/controllers/resourceController.js +254 -0
- package/templates/server/middleware/adminMiddleware.js +14 -0
- package/templates/server/middleware/authMiddleware.js +53 -0
- package/templates/server/middleware/errorMiddleware.js +55 -0
- package/templates/server/middleware/sessionAuthMiddleware.js +12 -0
- package/templates/server/middleware/uploadMiddleware.js +61 -0
- package/templates/server/middleware/validateMiddleware.js +61 -0
- package/templates/server/models/Resource.js +43 -0
- package/templates/server/models/User.js +48 -0
- package/templates/server/package.json +25 -0
- package/templates/server/routes/adminRoutes.js +138 -0
- package/templates/server/routes/authRoutes.js +21 -0
- package/templates/server/routes/resourceRoutes.js +27 -0
- package/templates/server/server.js +89 -0
- package/templates/server/utils/jwt.js +18 -0
- package/templates/server/views/dashboard.ejs +128 -0
- package/templates/server/views/login.ejs +48 -0
- package/templates/server/views/partials/footer.ejs +6 -0
- package/templates/server/views/partials/header.ejs +60 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import { Link } from 'react-router-dom';
|
|
3
|
+
import { api } from '../services/api';
|
|
4
|
+
import Loading from '../components/Loading';
|
|
5
|
+
|
|
6
|
+
const Resources = () => {
|
|
7
|
+
const [resources, setResources] = useState([]);
|
|
8
|
+
const [loading, setLoading] = useState(true);
|
|
9
|
+
const [search, setSearch] = useState('');
|
|
10
|
+
const [category, setCategory] = useState('All');
|
|
11
|
+
const [sort, setSort] = useState('newest');
|
|
12
|
+
|
|
13
|
+
const fetchResources = async () => {
|
|
14
|
+
setLoading(true);
|
|
15
|
+
try {
|
|
16
|
+
const response = await api.getResources({
|
|
17
|
+
search,
|
|
18
|
+
category,
|
|
19
|
+
sort,
|
|
20
|
+
});
|
|
21
|
+
if (response.success) {
|
|
22
|
+
setResources(response.data);
|
|
23
|
+
}
|
|
24
|
+
} catch (err) {
|
|
25
|
+
console.error('Failed to fetch resources:', err);
|
|
26
|
+
} finally {
|
|
27
|
+
setLoading(false);
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
fetchResources();
|
|
33
|
+
}, [category, sort]);
|
|
34
|
+
|
|
35
|
+
const handleSearchSubmit = (e) => {
|
|
36
|
+
e.preventDefault();
|
|
37
|
+
fetchResources();
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div className="container">
|
|
42
|
+
<div
|
|
43
|
+
style={{
|
|
44
|
+
display: 'flex',
|
|
45
|
+
justifyContent: 'space-between',
|
|
46
|
+
alignItems: 'center',
|
|
47
|
+
marginBottom: '1.5rem',
|
|
48
|
+
}}
|
|
49
|
+
>
|
|
50
|
+
<div>
|
|
51
|
+
<h2>All Resources</h2>
|
|
52
|
+
<p>Explore, search, filter, and download uploaded items.</p>
|
|
53
|
+
</div>
|
|
54
|
+
<Link to="/resources/new" className="btn btn-primary">
|
|
55
|
+
+ Add New Resource
|
|
56
|
+
</Link>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
{/* Filter and Search Bar */}
|
|
60
|
+
<div className="card" style={{ padding: '1rem' }}>
|
|
61
|
+
<form
|
|
62
|
+
onSubmit={handleSearchSubmit}
|
|
63
|
+
style={{
|
|
64
|
+
display: 'grid',
|
|
65
|
+
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
|
66
|
+
gap: '1rem',
|
|
67
|
+
alignItems: 'center',
|
|
68
|
+
}}
|
|
69
|
+
>
|
|
70
|
+
{/* Search Input */}
|
|
71
|
+
<div className="form-group" style={{ marginBottom: 0 }}>
|
|
72
|
+
<input
|
|
73
|
+
type="text"
|
|
74
|
+
className="form-control"
|
|
75
|
+
placeholder="Search by title or description..."
|
|
76
|
+
value={search}
|
|
77
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
78
|
+
/>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
{/* Category Filter */}
|
|
82
|
+
<div className="form-group" style={{ marginBottom: 0 }}>
|
|
83
|
+
<select
|
|
84
|
+
className="form-control"
|
|
85
|
+
value={category}
|
|
86
|
+
onChange={(e) => setCategory(e.target.value)}
|
|
87
|
+
>
|
|
88
|
+
<option value="All">All Categories</option>
|
|
89
|
+
<option value="General">General</option>
|
|
90
|
+
<option value="Documentation">Documentation</option>
|
|
91
|
+
<option value="Assignment">Assignment</option>
|
|
92
|
+
<option value="Project">Project</option>
|
|
93
|
+
<option value="Exam">Exam</option>
|
|
94
|
+
</select>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
{/* Sort Option */}
|
|
98
|
+
<div className="form-group" style={{ marginBottom: 0 }}>
|
|
99
|
+
<select
|
|
100
|
+
className="form-control"
|
|
101
|
+
value={sort}
|
|
102
|
+
onChange={(e) => setSort(e.target.value)}
|
|
103
|
+
>
|
|
104
|
+
<option value="newest">Sort by: Newest</option>
|
|
105
|
+
<option value="oldest">Sort by: Oldest</option>
|
|
106
|
+
<option value="title_asc">Title (A-Z)</option>
|
|
107
|
+
<option value="title_desc">Title (Z-A)</option>
|
|
108
|
+
</select>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
{/* Search Button */}
|
|
112
|
+
<button type="submit" className="btn btn-secondary">
|
|
113
|
+
Search
|
|
114
|
+
</button>
|
|
115
|
+
</form>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
{/* Resource List */}
|
|
119
|
+
{loading ? (
|
|
120
|
+
<Loading message="Fetching resources..." />
|
|
121
|
+
) : resources.length === 0 ? (
|
|
122
|
+
<div className="card" style={{ textAlign: 'center', padding: '3rem' }}>
|
|
123
|
+
<h3>No resources found</h3>
|
|
124
|
+
<p style={{ marginTop: '0.5rem' }}>
|
|
125
|
+
Try adjusting your search query or filter criteria.
|
|
126
|
+
</p>
|
|
127
|
+
</div>
|
|
128
|
+
) : (
|
|
129
|
+
<div className="grid-cols-3">
|
|
130
|
+
{resources.map((item) => (
|
|
131
|
+
<div key={item._id} className="card" style={{ display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
|
|
132
|
+
<div>
|
|
133
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '0.5rem' }}>
|
|
134
|
+
<span className="badge badge-primary">{item.category}</span>
|
|
135
|
+
<span style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>
|
|
136
|
+
{new Date(item.createdAt).toLocaleDateString()}
|
|
137
|
+
</span>
|
|
138
|
+
</div>
|
|
139
|
+
<h3 style={{ fontSize: '1.2rem', marginBottom: '0.5rem' }}>{item.title}</h3>
|
|
140
|
+
<p style={{ fontSize: '0.9rem', marginBottom: '1rem' }}>
|
|
141
|
+
{item.description ? (
|
|
142
|
+
item.description.length > 90
|
|
143
|
+
? `${item.description.substring(0, 90)}...`
|
|
144
|
+
: item.description
|
|
145
|
+
) : (
|
|
146
|
+
<em style={{ color: 'var(--text-secondary)' }}>No description provided.</em>
|
|
147
|
+
)}
|
|
148
|
+
</p>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<div>
|
|
152
|
+
{item.fileUrl && (
|
|
153
|
+
<div style={{ marginBottom: '0.75rem', fontSize: '0.85rem' }}>
|
|
154
|
+
<a
|
|
155
|
+
href={`http://localhost:5000/api/resources/${item._id}/download`}
|
|
156
|
+
target="_blank"
|
|
157
|
+
rel="noreferrer"
|
|
158
|
+
style={{ color: '#38bdf8', textDecoration: 'none' }}
|
|
159
|
+
>
|
|
160
|
+
📎 {item.fileName || 'Download Attachment'}
|
|
161
|
+
</a>
|
|
162
|
+
</div>
|
|
163
|
+
)}
|
|
164
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderTop: '1px solid var(--card-border)', paddingTop: '0.75rem' }}>
|
|
165
|
+
<span style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>
|
|
166
|
+
By: {item.createdBy?.name || 'Anonymous'}
|
|
167
|
+
</span>
|
|
168
|
+
<Link to={`/resources/${item._id}`} className="btn btn-secondary btn-sm">
|
|
169
|
+
Details →
|
|
170
|
+
</Link>
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
))}
|
|
175
|
+
</div>
|
|
176
|
+
)}
|
|
177
|
+
</div>
|
|
178
|
+
);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
export default Resources;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Navigate, useLocation } from 'react-router-dom';
|
|
3
|
+
import { useAuth } from '../context/AuthContext';
|
|
4
|
+
import Loading from '../components/Loading';
|
|
5
|
+
|
|
6
|
+
const ProtectedRoute = ({ children, requiredRole = null }) => {
|
|
7
|
+
const { isAuthenticated, loading, user } = useAuth();
|
|
8
|
+
const location = useLocation();
|
|
9
|
+
|
|
10
|
+
if (loading) {
|
|
11
|
+
return <Loading message="Verifying session..." />;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (!isAuthenticated) {
|
|
15
|
+
// Redirect to login preserving destination state
|
|
16
|
+
return <Navigate to="/login" state={{ from: location }} replace />;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Role check if required
|
|
20
|
+
if (requiredRole && user?.role !== requiredRole) {
|
|
21
|
+
return (
|
|
22
|
+
<div className="container" style={{ textAlign: 'center', marginTop: '3rem' }}>
|
|
23
|
+
<div className="card">
|
|
24
|
+
<h2>Access Forbidden</h2>
|
|
25
|
+
<p>You do not have the required role ({requiredRole}) to view this page.</p>
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return children;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export default ProtectedRoute;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
const API_BASE_URL = '/api';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Reusable HTTP helper using native fetch API
|
|
5
|
+
* Automatically includes Authorization Bearer token from localStorage
|
|
6
|
+
*/
|
|
7
|
+
async function request(endpoint, options = {}) {
|
|
8
|
+
const token = localStorage.getItem('token');
|
|
9
|
+
const headers = {
|
|
10
|
+
...options.headers,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// Add auth header if token exists and not explicitly overridden
|
|
14
|
+
if (token) {
|
|
15
|
+
headers['Authorization'] = `Bearer ${token}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// If body is NOT FormData, set JSON Content-Type
|
|
19
|
+
if (options.body && !(options.body instanceof FormData)) {
|
|
20
|
+
headers['Content-Type'] = 'application/json';
|
|
21
|
+
options.body = JSON.stringify(options.body);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
|
25
|
+
...options,
|
|
26
|
+
headers,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const data = await response.json().catch(() => ({}));
|
|
30
|
+
|
|
31
|
+
if (!response.ok) {
|
|
32
|
+
const error = new Error(data.message || 'An error occurred with the request');
|
|
33
|
+
error.status = response.status;
|
|
34
|
+
error.data = data;
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return data;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const api = {
|
|
42
|
+
// Auth API
|
|
43
|
+
login: (credentials) =>
|
|
44
|
+
request('/auth/login', { method: 'POST', body: credentials }),
|
|
45
|
+
register: (userData) =>
|
|
46
|
+
request('/auth/register', { method: 'POST', body: userData }),
|
|
47
|
+
getMe: () => request('/auth/me', { method: 'GET' }),
|
|
48
|
+
|
|
49
|
+
// Generic Resources API
|
|
50
|
+
getResources: (params = {}) => {
|
|
51
|
+
const searchParams = new URLSearchParams();
|
|
52
|
+
if (params.search) searchParams.append('search', params.search);
|
|
53
|
+
if (params.category && params.category !== 'All')
|
|
54
|
+
searchParams.append('category', params.category);
|
|
55
|
+
if (params.sort) searchParams.append('sort', params.sort);
|
|
56
|
+
|
|
57
|
+
const queryString = searchParams.toString();
|
|
58
|
+
return request(`/resources${queryString ? `?${queryString}` : ''}`, {
|
|
59
|
+
method: 'GET',
|
|
60
|
+
});
|
|
61
|
+
},
|
|
62
|
+
getResourceById: (id) => request(`/resources/${id}`, { method: 'GET' }),
|
|
63
|
+
createResource: (formData) =>
|
|
64
|
+
request('/resources', { method: 'POST', body: formData }),
|
|
65
|
+
updateResource: (id, formData) =>
|
|
66
|
+
request(`/resources/${id}`, { method: 'PUT', body: formData }),
|
|
67
|
+
deleteResource: (id) =>
|
|
68
|
+
request(`/resources/${id}`, { method: 'DELETE' }),
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export default api;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { defineConfig } from 'vite';
|
|
2
|
+
import react from '@vitejs/plugin-react';
|
|
3
|
+
|
|
4
|
+
// https://vitejs.dev/config/
|
|
5
|
+
export default defineConfig({
|
|
6
|
+
plugins: [react()],
|
|
7
|
+
server: {
|
|
8
|
+
port: 5173,
|
|
9
|
+
proxy: {
|
|
10
|
+
'/api': {
|
|
11
|
+
target: 'http://localhost:5000',
|
|
12
|
+
changeOrigin: true,
|
|
13
|
+
},
|
|
14
|
+
'/uploads': {
|
|
15
|
+
target: 'http://localhost:5000',
|
|
16
|
+
changeOrigin: true,
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const mongoose = require('mongoose');
|
|
2
|
+
|
|
3
|
+
const connectDB = async () => {
|
|
4
|
+
try {
|
|
5
|
+
const conn = await mongoose.connect(process.env.MONGO_URI || 'mongodb://127.0.0.1:27017/mern_exam');
|
|
6
|
+
console.log(`[MongoDB] Connected to database: ${conn.connection.name} @ ${conn.connection.host}`);
|
|
7
|
+
} catch (error) {
|
|
8
|
+
console.error(`[MongoDB Error] Database connection failed: ${error.message}`);
|
|
9
|
+
process.exit(1);
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
module.exports = connectDB;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
const bcrypt = require('bcryptjs');
|
|
2
|
+
const User = require('../models/User');
|
|
3
|
+
const { generateToken } = require('../utils/jwt');
|
|
4
|
+
|
|
5
|
+
// @desc Register a new user
|
|
6
|
+
// @route POST /api/auth/register
|
|
7
|
+
// @access Public
|
|
8
|
+
const registerUser = async (req, res, next) => {
|
|
9
|
+
try {
|
|
10
|
+
const { name, email, password, role } = req.body;
|
|
11
|
+
|
|
12
|
+
// Check if user already exists
|
|
13
|
+
const userExists = await User.findOne({ email: email.toLowerCase() });
|
|
14
|
+
if (userExists) {
|
|
15
|
+
return res.status(400).json({
|
|
16
|
+
success: false,
|
|
17
|
+
message: 'A user with this email already exists.',
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Hash password
|
|
22
|
+
const salt = await bcrypt.genSalt(10);
|
|
23
|
+
const hashedPassword = await bcrypt.hash(password, salt);
|
|
24
|
+
|
|
25
|
+
// Create user
|
|
26
|
+
const user = await User.create({
|
|
27
|
+
name,
|
|
28
|
+
email: email.toLowerCase(),
|
|
29
|
+
password: hashedPassword,
|
|
30
|
+
role: role || 'student',
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Generate JWT
|
|
34
|
+
const token = generateToken(user);
|
|
35
|
+
|
|
36
|
+
return res.status(201).json({
|
|
37
|
+
success: true,
|
|
38
|
+
message: 'User registered successfully.',
|
|
39
|
+
token,
|
|
40
|
+
user: {
|
|
41
|
+
id: user._id,
|
|
42
|
+
name: user.name,
|
|
43
|
+
email: user.email,
|
|
44
|
+
role: user.role,
|
|
45
|
+
isActive: user.isActive,
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
} catch (error) {
|
|
49
|
+
next(error);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// @desc Authenticate user & get token
|
|
54
|
+
// @route POST /api/auth/login
|
|
55
|
+
// @access Public
|
|
56
|
+
const loginUser = async (req, res, next) => {
|
|
57
|
+
try {
|
|
58
|
+
const { email, password } = req.body;
|
|
59
|
+
|
|
60
|
+
// Check for user
|
|
61
|
+
const user = await User.findOne({ email: email.toLowerCase() });
|
|
62
|
+
if (!user) {
|
|
63
|
+
return res.status(401).json({
|
|
64
|
+
success: false,
|
|
65
|
+
message: 'Invalid email or password.',
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Check if active
|
|
70
|
+
if (!user.isActive) {
|
|
71
|
+
return res.status(403).json({
|
|
72
|
+
success: false,
|
|
73
|
+
message: 'Your account has been deactivated. Please contact an admin.',
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Compare password
|
|
78
|
+
const isMatch = await bcrypt.compare(password, user.password);
|
|
79
|
+
if (!isMatch) {
|
|
80
|
+
return res.status(401).json({
|
|
81
|
+
success: false,
|
|
82
|
+
message: 'Invalid email or password.',
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Generate token
|
|
87
|
+
const token = generateToken(user);
|
|
88
|
+
|
|
89
|
+
return res.status(200).json({
|
|
90
|
+
success: true,
|
|
91
|
+
message: 'Login successful.',
|
|
92
|
+
token,
|
|
93
|
+
user: {
|
|
94
|
+
id: user._id,
|
|
95
|
+
name: user.name,
|
|
96
|
+
email: user.email,
|
|
97
|
+
role: user.role,
|
|
98
|
+
isActive: user.isActive,
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
} catch (error) {
|
|
102
|
+
next(error);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// @desc Get current authenticated user profile
|
|
107
|
+
// @route GET /api/auth/me
|
|
108
|
+
// @access Private (JWT)
|
|
109
|
+
const getMe = async (req, res, next) => {
|
|
110
|
+
try {
|
|
111
|
+
return res.status(200).json({
|
|
112
|
+
success: true,
|
|
113
|
+
user: {
|
|
114
|
+
id: req.user._id,
|
|
115
|
+
name: req.user.name,
|
|
116
|
+
email: req.user.email,
|
|
117
|
+
role: req.user.role,
|
|
118
|
+
isActive: req.user.isActive,
|
|
119
|
+
createdAt: req.user.createdAt,
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
} catch (error) {
|
|
123
|
+
next(error);
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
module.exports = {
|
|
128
|
+
registerUser,
|
|
129
|
+
loginUser,
|
|
130
|
+
getMe,
|
|
131
|
+
};
|