feat: Docker部署与CI/CD集成, 搜索栏修复, 上传目录改为data
This commit is contained in:
16
pages/_app.tsx
Normal file
16
pages/_app.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import '@/styles/globals.css';
|
||||
import type { AppProps } from 'next/app';
|
||||
import Head from 'next/head';
|
||||
import { ToastProvider } from '@/components/ToastProvider';
|
||||
|
||||
export default function App({ Component, pageProps }: AppProps) {
|
||||
return (
|
||||
<ToastProvider>
|
||||
<Head>
|
||||
<title>NEXUS_MAT.OS</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
</Head>
|
||||
<Component {...pageProps} />
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
16
pages/_document.tsx
Normal file
16
pages/_document.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import Document, { Html, Head, Main, NextScript } from 'next/document';
|
||||
|
||||
export default class MyDocument extends Document {
|
||||
render() {
|
||||
return (
|
||||
<Html lang="zh-CN">
|
||||
<Head />
|
||||
<body>
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
61
pages/api/v1/admin/config.ts
Normal file
61
pages/api/v1/admin/config.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { NextApiResponse } from 'next';
|
||||
import { AuthenticatedRequest } from '../../../../lib/middleware/authMiddleware';
|
||||
import { requireAdmin } from '../../../../lib/middleware/adminMiddleware';
|
||||
import { getServerConfig, setServerConfig } from '../../../../lib/serverConfig';
|
||||
import { resetPrisma } from '../../../../lib/prisma';
|
||||
|
||||
const SYSTEM_CONFIG = {
|
||||
maintenanceMode: false,
|
||||
apiVersion: '1.0.0',
|
||||
maxUploadMB: getServerConfig().uploadMaxMB,
|
||||
dbHost: getServerConfig().dbHost,
|
||||
dbPort: getServerConfig().dbPort,
|
||||
dbUser: getServerConfig().dbUser,
|
||||
dbPass: getServerConfig().dbPass,
|
||||
dbName: getServerConfig().dbName,
|
||||
uploadDir: getServerConfig().uploadDir,
|
||||
};
|
||||
|
||||
export default async function handler(req: AuthenticatedRequest, res: NextApiResponse) {
|
||||
// Require admin authentication
|
||||
const isAdmin = await requireAdmin(req, res);
|
||||
if (!isAdmin) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET') {
|
||||
// Return non-sensitive system configuration
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
data: SYSTEM_CONFIG
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === 'PUT') {
|
||||
const body = req.body || {};
|
||||
const before = getServerConfig();
|
||||
const nextCfg = setServerConfig({
|
||||
uploadMaxMB: body.maxUploadMB,
|
||||
uploadDir: body.uploadDir,
|
||||
dbHost: body.dbHost,
|
||||
dbPort: body.dbPort,
|
||||
dbUser: body.dbUser,
|
||||
dbPass: body.dbPass,
|
||||
dbName: body.dbName,
|
||||
});
|
||||
SYSTEM_CONFIG.maxUploadMB = nextCfg.uploadMaxMB;
|
||||
SYSTEM_CONFIG.uploadDir = nextCfg.uploadDir;
|
||||
SYSTEM_CONFIG.dbHost = nextCfg.dbHost;
|
||||
SYSTEM_CONFIG.dbPort = nextCfg.dbPort;
|
||||
SYSTEM_CONFIG.dbUser = nextCfg.dbUser;
|
||||
SYSTEM_CONFIG.dbPass = nextCfg.dbPass;
|
||||
SYSTEM_CONFIG.dbName = nextCfg.dbName;
|
||||
const dbChanged = before.dbHost !== nextCfg.dbHost || before.dbPort !== nextCfg.dbPort || before.dbUser !== nextCfg.dbUser || before.dbPass !== nextCfg.dbPass || before.dbName !== nextCfg.dbName;
|
||||
if (dbChanged) {
|
||||
resetPrisma();
|
||||
}
|
||||
return res.status(200).json({ success: true, message: 'Configuration updated successfully', data: SYSTEM_CONFIG });
|
||||
}
|
||||
|
||||
return res.status(405).json({ success: false, error: 'Method not allowed' });
|
||||
}
|
||||
24
pages/api/v1/admin/users.ts
Normal file
24
pages/api/v1/admin/users.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { NextApiResponse } from 'next';
|
||||
import { AuthenticatedRequest } from '../../../../lib/middleware/authMiddleware';
|
||||
import { requireAdmin } from '../../../../lib/middleware/adminMiddleware';
|
||||
import { UserService } from '../../../../backend/services/userService';
|
||||
|
||||
export default async function handler(req: AuthenticatedRequest, res: NextApiResponse) {
|
||||
if (req.method !== 'GET') {
|
||||
return res.status(405).json({ success: false, error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
// Require admin authentication
|
||||
const isAdmin = await requireAdmin(req, res);
|
||||
if (!isAdmin) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const users = await UserService.getAllUsers();
|
||||
return res.status(200).json({ success: true, data: users });
|
||||
} catch (error) {
|
||||
console.error('Error fetching users:', error);
|
||||
return res.status(500).json({ success: false, error: 'Failed to fetch users' });
|
||||
}
|
||||
}
|
||||
28
pages/api/v1/admin/users/[id]/role.ts
Normal file
28
pages/api/v1/admin/users/[id]/role.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { NextApiResponse } from 'next';
|
||||
import { AuthenticatedRequest } from '@/lib/middleware/authMiddleware';
|
||||
import { requireAdmin } from '@/lib/middleware/adminMiddleware';
|
||||
import { UserService } from '@/backend/services/userService';
|
||||
|
||||
export default async function handler(req: AuthenticatedRequest, res: NextApiResponse) {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ success: false, error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
const isAdmin = await requireAdmin(req, res);
|
||||
if (!isAdmin) return;
|
||||
|
||||
const { id } = req.query;
|
||||
const { role } = req.body || {};
|
||||
|
||||
if (typeof id !== 'string' || typeof role !== 'string') {
|
||||
return res.status(400).json({ success: false, error: 'Invalid input' });
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await UserService.updateUserRole(id, role as any);
|
||||
return res.status(200).json({ success: true, data: updated });
|
||||
} catch (error) {
|
||||
console.error('Error updating role:', error);
|
||||
return res.status(500).json({ success: false, error: 'Failed to update role' });
|
||||
}
|
||||
}
|
||||
30
pages/api/v1/admin/users/[id]/toggle-status.ts
Normal file
30
pages/api/v1/admin/users/[id]/toggle-status.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { NextApiResponse } from 'next';
|
||||
import { AuthenticatedRequest } from '../../../../../../lib/middleware/authMiddleware';
|
||||
import { requireAdmin } from '../../../../../../lib/middleware/adminMiddleware';
|
||||
import { UserService } from '../../../../../../backend/services/userService';
|
||||
|
||||
export default async function handler(req: AuthenticatedRequest, res: NextApiResponse) {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ success: false, error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
// Require admin authentication
|
||||
const isAdmin = await requireAdmin(req, res);
|
||||
if (!isAdmin) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { id } = req.query;
|
||||
|
||||
if (typeof id !== 'string') {
|
||||
return res.status(400).json({ success: false, error: 'Invalid user ID' });
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedUser = await UserService.toggleUserStatus(id);
|
||||
return res.status(200).json({ success: true, data: updatedUser });
|
||||
} catch (error) {
|
||||
console.error('Error toggling user status:', error);
|
||||
return res.status(500).json({ success: false, error: 'Failed to toggle user status' });
|
||||
}
|
||||
}
|
||||
48
pages/api/v1/auth/login.ts
Normal file
48
pages/api/v1/auth/login.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { UserService } from '../../../../backend/services/userService';
|
||||
import { generateToken } from '../../../../lib/auth';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ success: false, error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
|
||||
// Validate input
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ success: false, error: 'Username and password are required' });
|
||||
}
|
||||
|
||||
// Authenticate user
|
||||
const user = await UserService.authenticateUser(username, password);
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({ success: false, error: 'Invalid username or password' });
|
||||
}
|
||||
|
||||
// Check if user is banned
|
||||
if (user.status === 'BANNED') {
|
||||
return res.status(403).json({ success: false, error: 'Account has been banned' });
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
const token = generateToken(user.id);
|
||||
|
||||
// Set HTTP-only cookie
|
||||
res.setHeader('Set-Cookie', `token=${token}; HttpOnly; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Strict`);
|
||||
|
||||
// Return user data
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
user,
|
||||
token // Also return token in body for non-cookie clients
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
return res.status(500).json({ success: false, error: 'Login failed' });
|
||||
}
|
||||
}
|
||||
15
pages/api/v1/auth/logout.ts
Normal file
15
pages/api/v1/auth/logout.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ success: false, error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
// Clear the authentication cookie
|
||||
res.setHeader('Set-Cookie', 'token=; HttpOnly; Path=/; Max-Age=0; SameSite=Strict');
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: 'Logged out successfully'
|
||||
});
|
||||
}
|
||||
20
pages/api/v1/auth/me.ts
Normal file
20
pages/api/v1/auth/me.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { NextApiResponse } from 'next';
|
||||
import { AuthenticatedRequest, requireAuth } from '../../../../lib/middleware/authMiddleware';
|
||||
|
||||
export default async function handler(req: AuthenticatedRequest, res: NextApiResponse) {
|
||||
if (req.method !== 'GET') {
|
||||
return res.status(405).json({ success: false, error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
// Require authentication
|
||||
const isAuthenticated = await requireAuth(req, res);
|
||||
if (!isAuthenticated) {
|
||||
return; // requireAuth already sent the response
|
||||
}
|
||||
|
||||
// Return current user data
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
data: req.user
|
||||
});
|
||||
}
|
||||
59
pages/api/v1/auth/register.ts
Normal file
59
pages/api/v1/auth/register.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { UserService } from '../../../../backend/services/userService';
|
||||
import { generateToken } from '../../../../lib/auth';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ success: false, error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
try {
|
||||
const { username, password, email } = req.body;
|
||||
|
||||
// Validate input
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ success: false, error: 'Username and password are required' });
|
||||
}
|
||||
|
||||
if (username.length < 3) {
|
||||
return res.status(400).json({ success: false, error: 'Username must be at least 3 characters' });
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
return res.status(400).json({ success: false, error: 'Password must be at least 6 characters' });
|
||||
}
|
||||
|
||||
// Check if username already exists
|
||||
const existingUser = await UserService.getUserByUsername(username);
|
||||
if (existingUser) {
|
||||
return res.status(409).json({ success: false, error: 'Username already taken' });
|
||||
}
|
||||
|
||||
// Create new user
|
||||
const user = await UserService.createUser(username, password, email);
|
||||
|
||||
// Generate JWT token
|
||||
const token = generateToken(user.id);
|
||||
|
||||
// Set HTTP-only cookie
|
||||
res.setHeader('Set-Cookie', `token=${token}; HttpOnly; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Strict`);
|
||||
|
||||
// Return user data (without password)
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: {
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
avatarUrl: user.avatarUrl,
|
||||
role: user.role,
|
||||
status: user.status
|
||||
},
|
||||
token // Also return token in body for non-cookie clients
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
return res.status(500).json({ success: false, error: 'Registration failed' });
|
||||
}
|
||||
}
|
||||
31
pages/api/v1/files/[name].ts
Normal file
31
pages/api/v1/files/[name].ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { getServerConfig } from '../../../../lib/serverConfig';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { name } = req.query;
|
||||
if (typeof name !== 'string') {
|
||||
return res.status(400).send('Invalid file name');
|
||||
}
|
||||
|
||||
try {
|
||||
const baseDir = path.join(process.cwd(), getServerConfig().uploadDir);
|
||||
const filePath = path.join(baseDir, name);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return res.status(404).send('File not found');
|
||||
}
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const type = ext === '.zip' ? 'application/zip'
|
||||
: ext === '.mp4' ? 'video/mp4'
|
||||
: ext === '.webm' ? 'video/webm'
|
||||
: ext === '.mov' ? 'video/quicktime'
|
||||
: 'application/octet-stream';
|
||||
res.setHeader('Content-Type', type);
|
||||
const stream = fs.createReadStream(filePath);
|
||||
stream.pipe(res);
|
||||
} catch (e) {
|
||||
res.status(500).send('Internal error');
|
||||
}
|
||||
}
|
||||
|
||||
62
pages/api/v1/materials/[id].ts
Normal file
62
pages/api/v1/materials/[id].ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { NextApiResponse } from 'next';
|
||||
import { AuthenticatedRequest, requireAuth, optionalAuth } from '../../../../lib/middleware/authMiddleware';
|
||||
import { MaterialService } from '../../../../backend/services/materialService';
|
||||
import { UserRole } from '../../../../types';
|
||||
|
||||
export default async function handler(req: AuthenticatedRequest, res: NextApiResponse) {
|
||||
const { id } = req.query;
|
||||
|
||||
if (typeof id !== 'string') {
|
||||
return res.status(400).json({ success: false, error: 'Invalid material ID' });
|
||||
}
|
||||
|
||||
// GET: Get material by ID
|
||||
if (req.method === 'GET') {
|
||||
// Optional auth
|
||||
await optionalAuth(req);
|
||||
|
||||
try {
|
||||
const material = await MaterialService.getMaterialById(id);
|
||||
|
||||
if (!material) {
|
||||
return res.status(404).json({ success: false, error: 'Material not found' });
|
||||
}
|
||||
|
||||
return res.status(200).json({ success: true, data: material });
|
||||
} catch (error) {
|
||||
console.error('Error fetching material:', error);
|
||||
return res.status(500).json({ success: false, error: 'Failed to fetch material' });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE: Delete material
|
||||
if (req.method === 'DELETE') {
|
||||
// Require authentication
|
||||
const isAuthenticated = await requireAuth(req, res);
|
||||
if (!isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get material to check authorization
|
||||
const material = await MaterialService.getMaterialById(id);
|
||||
|
||||
if (!material) {
|
||||
return res.status(404).json({ success: false, error: 'Material not found' });
|
||||
}
|
||||
|
||||
// Check if user is author or admin
|
||||
if (material.author.id !== req.user!.id && req.user!.role !== UserRole.ADMIN) {
|
||||
return res.status(403).json({ success: false, error: 'Not authorized to delete this material' });
|
||||
}
|
||||
|
||||
await MaterialService.deleteMaterial(id);
|
||||
return res.status(200).json({ success: true, message: 'Material deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting material:', error);
|
||||
return res.status(500).json({ success: false, error: 'Failed to delete material' });
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(405).json({ success: false, error: 'Method not allowed' });
|
||||
}
|
||||
34
pages/api/v1/materials/[id]/comments.ts
Normal file
34
pages/api/v1/materials/[id]/comments.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { NextApiResponse } from 'next';
|
||||
import { AuthenticatedRequest, requireAuth } from '../../../../../lib/middleware/authMiddleware';
|
||||
import { MaterialService } from '../../../../../backend/services/materialService';
|
||||
|
||||
export default async function handler(req: AuthenticatedRequest, res: NextApiResponse) {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ success: false, error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
// Require authentication
|
||||
const isAuthenticated = await requireAuth(req, res);
|
||||
if (!isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { id } = req.query;
|
||||
const { content } = req.body;
|
||||
|
||||
if (typeof id !== 'string') {
|
||||
return res.status(400).json({ success: false, error: 'Invalid material ID' });
|
||||
}
|
||||
|
||||
if (!content || !content.trim()) {
|
||||
return res.status(400).json({ success: false, error: 'Comment content is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const comment = await MaterialService.addComment(id, req.user!.id, content);
|
||||
return res.status(201).json({ success: true, data: comment });
|
||||
} catch (error) {
|
||||
console.error('Error adding comment:', error);
|
||||
return res.status(500).json({ success: false, error: 'Failed to add comment' });
|
||||
}
|
||||
}
|
||||
29
pages/api/v1/materials/[id]/favorite.ts
Normal file
29
pages/api/v1/materials/[id]/favorite.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { NextApiResponse } from 'next';
|
||||
import { AuthenticatedRequest, requireAuth } from '../../../../../lib/middleware/authMiddleware';
|
||||
import { MaterialService } from '../../../../../backend/services/materialService';
|
||||
|
||||
export default async function handler(req: AuthenticatedRequest, res: NextApiResponse) {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ success: false, error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
// Require authentication
|
||||
const isAuthenticated = await requireAuth(req, res);
|
||||
if (!isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { id } = req.query;
|
||||
|
||||
if (typeof id !== 'string') {
|
||||
return res.status(400).json({ success: false, error: 'Invalid material ID' });
|
||||
}
|
||||
|
||||
try {
|
||||
const newFavoriteCount = await MaterialService.toggleFavorite(id, req.user!.id);
|
||||
return res.status(200).json({ success: true, data: { favorites: newFavoriteCount } });
|
||||
} catch (error) {
|
||||
console.error('Error toggling favorite:', error);
|
||||
return res.status(500).json({ success: false, error: 'Failed to toggle favorite' });
|
||||
}
|
||||
}
|
||||
43
pages/api/v1/materials/index.ts
Normal file
43
pages/api/v1/materials/index.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { NextApiResponse } from 'next';
|
||||
import { AuthenticatedRequest, requireAuth, optionalAuth } from '../../../../lib/middleware/authMiddleware';
|
||||
import { MaterialService } from '../../../../backend/services/materialService';
|
||||
|
||||
export default async function handler(req: AuthenticatedRequest, res: NextApiResponse) {
|
||||
// GET: List materials
|
||||
if (req.method === 'GET') {
|
||||
// Optional auth - attach user if logged in
|
||||
await optionalAuth(req);
|
||||
|
||||
try {
|
||||
const type = req.query.type as any;
|
||||
const page = parseInt((req.query.page as string) || '1', 10) || 1;
|
||||
const limit = parseInt((req.query.limit as string) || '20', 10) || 20;
|
||||
const q = (req.query.q as string) || '';
|
||||
const { items, total } = await MaterialService.getAllMaterials(type, page, limit, q);
|
||||
const hasNext = page * limit < total;
|
||||
return res.status(200).json({ success: true, data: { items, total, page, limit, hasNext } });
|
||||
} catch (error) {
|
||||
console.error('Error fetching materials:', error);
|
||||
return res.status(500).json({ success: false, error: 'Failed to fetch materials' });
|
||||
}
|
||||
}
|
||||
|
||||
// POST: Create material
|
||||
if (req.method === 'POST') {
|
||||
// Require authentication
|
||||
const isAuthenticated = await requireAuth(req, res);
|
||||
if (!isAuthenticated) {
|
||||
return; // requireAuth already sent the response
|
||||
}
|
||||
|
||||
try {
|
||||
const newMaterial = await MaterialService.createMaterial(req.user!.id, req.body);
|
||||
return res.status(201).json({ success: true, data: newMaterial });
|
||||
} catch (error) {
|
||||
console.error('Error creating material:', error);
|
||||
return res.status(500).json({ success: false, error: 'Failed to create material' });
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(405).json({ success: false, error: 'Method not allowed' });
|
||||
}
|
||||
87
pages/api/v1/materials/upload-video.ts
Normal file
87
pages/api/v1/materials/upload-video.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { NextApiResponse } from 'next';
|
||||
import type { AuthenticatedRequest } from '../../../../lib/middleware/authMiddleware';
|
||||
import { requireAuth } from '../../../../lib/middleware/authMiddleware';
|
||||
import { getServerConfig } from '../../../../lib/serverConfig';
|
||||
import { UserRole } from '../../../../types';
|
||||
import { MaterialService } from '../../../../backend/services/materialService';
|
||||
import formidable, { Files, File } from 'formidable';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default async function handler(req: AuthenticatedRequest, res: NextApiResponse) {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ success: false, error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
const isAuthenticated = await requireAuth(req, res);
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
if (req.user!.role !== UserRole.MANAGER && req.user!.role !== UserRole.ADMIN) {
|
||||
return res.status(403).json({ success: false, error: 'Manager role required' });
|
||||
}
|
||||
|
||||
const { uploadMaxMB } = getServerConfig();
|
||||
const maxBytes = uploadMaxMB * 1024 * 1024;
|
||||
|
||||
const form = formidable({
|
||||
maxFileSize: maxBytes,
|
||||
multiples: false,
|
||||
filter: ({ mimetype, originalFilename }) => {
|
||||
// Allow common video types
|
||||
const name = (originalFilename || '').toLowerCase();
|
||||
return mimetype?.startsWith('video/') || name.endsWith('.mp4') || name.endsWith('.webm') || name.endsWith('.mov');
|
||||
},
|
||||
});
|
||||
|
||||
const getFirstFile = (files: Files | undefined): File | null => {
|
||||
if (!files) return null;
|
||||
const entries = Object.values(files || {});
|
||||
if (!entries.length) return null;
|
||||
const candidate = entries[0] as any;
|
||||
if (Array.isArray(candidate)) return candidate[0] || null;
|
||||
return candidate || null;
|
||||
};
|
||||
|
||||
try {
|
||||
const parsed: { fields: any; files: Files } = await new Promise((resolve, reject) => {
|
||||
form.parse(req as any, (err, fields, files) => {
|
||||
if (err) reject(err);
|
||||
else resolve({ fields, files });
|
||||
});
|
||||
});
|
||||
const { fields, files } = parsed;
|
||||
const file = getFirstFile(files);
|
||||
if (!file) return res.status(400).json({ success: false, error: 'No file provided' });
|
||||
if ((file.size || 0) > maxBytes) return res.status(413).json({ success: false, error: `File too large. Max ${uploadMaxMB}MB` });
|
||||
|
||||
const uploadsDir = path.join(process.cwd(), getServerConfig().uploadDir);
|
||||
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
|
||||
const filename = `video_${Date.now()}_${path.basename(file.originalFilename || 'video.mp4')}`;
|
||||
const destPath = path.join(uploadsDir, filename);
|
||||
const srcPath = (file as any).filepath || (file as any).path;
|
||||
if (!srcPath) return res.status(400).json({ success: false, error: 'Invalid upload temp path' });
|
||||
await fs.promises.copyFile(srcPath, destPath);
|
||||
|
||||
const contentUrl = `/api/v1/files/${filename}`;
|
||||
|
||||
const newMaterial = await MaterialService.createMaterial(req.user!.id, {
|
||||
title: fields?.title?.toString() || 'Video Asset',
|
||||
description: fields?.description?.toString() || 'Uploaded video asset',
|
||||
type: 'VIDEO',
|
||||
contentUrl,
|
||||
tags: (fields?.tags?.toString() || '').split(',').filter(Boolean),
|
||||
});
|
||||
|
||||
return res.status(201).json({ success: true, data: newMaterial });
|
||||
} catch (error: any) {
|
||||
console.error('Upload VIDEO error:', error);
|
||||
return res.status(500).json({ success: false, error: error.message || 'Upload failed' });
|
||||
}
|
||||
}
|
||||
80
pages/api/v1/materials/upload-zip.ts
Normal file
80
pages/api/v1/materials/upload-zip.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { NextApiResponse } from 'next';
|
||||
import type { AuthenticatedRequest } from '../../../../lib/middleware/authMiddleware';
|
||||
import { requireAuth } from '../../../../lib/middleware/authMiddleware';
|
||||
import { getServerConfig } from '../../../../lib/serverConfig';
|
||||
import { MaterialService } from '../../../../backend/services/materialService';
|
||||
import formidable, { Files, File } from 'formidable';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default async function handler(req: AuthenticatedRequest, res: NextApiResponse) {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ success: false, error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
const isAuthenticated = await requireAuth(req, res);
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
const { uploadMaxMB } = getServerConfig();
|
||||
const maxBytes = uploadMaxMB * 1024 * 1024;
|
||||
|
||||
const form = formidable({
|
||||
maxFileSize: maxBytes,
|
||||
multiples: false,
|
||||
filter: ({ mimetype, originalFilename }) => {
|
||||
return (mimetype === 'application/zip' || (originalFilename || '').toLowerCase().endsWith('.zip'));
|
||||
},
|
||||
});
|
||||
|
||||
const getFirstFile = (files: Files | undefined): File | null => {
|
||||
if (!files) return null;
|
||||
const entries = Object.values(files || {});
|
||||
if (!entries.length) return null;
|
||||
const candidate = entries[0] as any;
|
||||
if (Array.isArray(candidate)) return candidate[0] || null;
|
||||
return candidate || null;
|
||||
};
|
||||
|
||||
try {
|
||||
const parsed: { fields: any; files: Files } = await new Promise((resolve, reject) => {
|
||||
form.parse(req as any, (err, fields, files) => {
|
||||
if (err) reject(err);
|
||||
else resolve({ fields, files });
|
||||
});
|
||||
});
|
||||
const { fields, files } = parsed;
|
||||
const file = getFirstFile(files);
|
||||
if (!file) return res.status(400).json({ success: false, error: 'No file provided' });
|
||||
if ((file.size || 0) > maxBytes) return res.status(413).json({ success: false, error: `File too large. Max ${uploadMaxMB}MB` });
|
||||
|
||||
const uploadsDir = path.join(process.cwd(), getServerConfig().uploadDir);
|
||||
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
|
||||
const filename = `zip_${Date.now()}_${path.basename(file.originalFilename || 'asset.zip')}`;
|
||||
const destPath = path.join(uploadsDir, filename);
|
||||
const srcPath = (file as any).filepath || (file as any).path;
|
||||
if (!srcPath) return res.status(400).json({ success: false, error: 'Invalid upload temp path' });
|
||||
await fs.promises.copyFile(srcPath, destPath);
|
||||
|
||||
const contentUrl = `/api/v1/files/${filename}`;
|
||||
|
||||
const newMaterial = await MaterialService.createMaterial(req.user!.id, {
|
||||
title: fields?.title?.toString() || 'ZIP Asset',
|
||||
description: fields?.description?.toString() || 'Uploaded ZIP asset',
|
||||
type: 'ASSET_ZIP',
|
||||
contentUrl,
|
||||
tags: (fields?.tags?.toString() || '').split(',').filter(Boolean),
|
||||
});
|
||||
|
||||
return res.status(201).json({ success: true, data: newMaterial });
|
||||
} catch (error: any) {
|
||||
console.error('Upload ZIP error:', error);
|
||||
return res.status(500).json({ success: false, error: error.message || 'Upload failed' });
|
||||
}
|
||||
}
|
||||
30
pages/api/v1/users/me.ts
Normal file
30
pages/api/v1/users/me.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { NextApiResponse } from 'next';
|
||||
import { AuthenticatedRequest, requireAuth } from '../../../../lib/middleware/authMiddleware';
|
||||
import { UserService } from '../../../../backend/services/userService';
|
||||
|
||||
export default async function handler(req: AuthenticatedRequest, res: NextApiResponse) {
|
||||
if (req.method !== 'PATCH') {
|
||||
return res.status(405).json({ success: false, error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
// Require authentication
|
||||
const isAuthenticated = await requireAuth(req, res);
|
||||
if (!isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { username, avatarUrl } = req.body;
|
||||
|
||||
// Update user
|
||||
const updatedUser = await UserService.updateUser(req.user!.id, {
|
||||
username,
|
||||
avatarUrl
|
||||
});
|
||||
|
||||
return res.status(200).json({ success: true, data: updatedUser });
|
||||
} catch (error) {
|
||||
console.error('Error updating user:', error);
|
||||
return res.status(500).json({ success: false, error: 'Failed to update user' });
|
||||
}
|
||||
}
|
||||
23
pages/api/v1/users/me/favorites.ts
Normal file
23
pages/api/v1/users/me/favorites.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { NextApiResponse } from 'next';
|
||||
import { AuthenticatedRequest, requireAuth } from '@/lib/middleware/authMiddleware';
|
||||
import { MaterialService } from '@/backend/services/materialService';
|
||||
|
||||
export default async function handler(req: AuthenticatedRequest, res: NextApiResponse) {
|
||||
if (req.method !== 'GET') {
|
||||
return res.status(405).json({ success: false, error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
try {
|
||||
const isAuthenticated = await requireAuth(req, res);
|
||||
if (!isAuthenticated) return;
|
||||
const page = parseInt((req.query.page as string) || '1', 10) || 1;
|
||||
const limit = parseInt((req.query.limit as string) || '20', 10) || 20;
|
||||
const { items, total } = await MaterialService.getFavoritedMaterialsByUser(req.user!.id, page, limit);
|
||||
const hasNext = page * limit < total;
|
||||
|
||||
return res.status(200).json({ success: true, data: { items, total, page, limit, hasNext }, timestamp: new Date().toISOString() });
|
||||
} catch (error: any) {
|
||||
console.error('Get user favorites error:', error);
|
||||
return res.status(500).json({ success: false, error: error.message || 'Failed to get favorites' });
|
||||
}
|
||||
}
|
||||
24
pages/api/v1/users/me/materials.ts
Normal file
24
pages/api/v1/users/me/materials.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { NextApiResponse } from 'next';
|
||||
import { AuthenticatedRequest, requireAuth } from '@/lib/middleware/authMiddleware';
|
||||
import { MaterialService } from '@/backend/services/materialService';
|
||||
|
||||
export default async function handler(req: AuthenticatedRequest, res: NextApiResponse) {
|
||||
if (req.method !== 'GET') {
|
||||
return res.status(405).json({ success: false, error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
try {
|
||||
const isAuthenticated = await requireAuth(req, res);
|
||||
if (!isAuthenticated) return;
|
||||
const { user } = req;
|
||||
const page = parseInt((req.query.page as string) || '1', 10) || 1;
|
||||
const limit = parseInt((req.query.limit as string) || '20', 10) || 20;
|
||||
const { items, total } = await MaterialService.getMaterialsByAuthor(user!.id, page, limit);
|
||||
const hasNext = page * limit < total;
|
||||
|
||||
return res.status(200).json({ success: true, data: { items, total, page, limit, hasNext }, timestamp: new Date().toISOString() });
|
||||
} catch (error: any) {
|
||||
console.error('Get user materials error:', error);
|
||||
return res.status(500).json({ success: false, error: error.message || 'Failed to get materials' });
|
||||
}
|
||||
}
|
||||
137
pages/auth/login.tsx
Normal file
137
pages/auth/login.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { Hexagon, ArrowRight, Lock, User } from 'lucide-react';
|
||||
import Head from 'next/head';
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleLogin = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const username = formData.get('username') as string;
|
||||
const password = formData.get('password') as string;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// Also set REAL mode after successful login
|
||||
localStorage.setItem('NEXUS_DATA_MODE', 'REAL');
|
||||
router.push('/');
|
||||
} else {
|
||||
setError(data.error || 'Login failed');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Network error. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center relative overflow-hidden bg-[#020202]">
|
||||
<Head>
|
||||
<title>NEXUS_MAT.OS</title>
|
||||
</Head>
|
||||
|
||||
{/* Animated Background */}
|
||||
<div className="absolute inset-0 bg-[url('https://grainy-gradients.vercel.app/noise.svg')] opacity-20"></div>
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] h-[800px] bg-cyber-neon/5 rounded-full blur-[100px] pointer-events-none"></div>
|
||||
|
||||
<div className="w-full max-w-md p-8 relative z-10">
|
||||
<div className="mb-10 text-center">
|
||||
<Link href="/" className="inline-flex items-center gap-2 group cursor-pointer mb-6">
|
||||
<Hexagon className="text-cyber-neon w-10 h-10 group-hover:rotate-180 transition-transform duration-700" />
|
||||
<span className="text-3xl font-mono font-bold tracking-tighter text-white">
|
||||
NEXUS_MAT<span className="text-cyber-neon">.OS</span>
|
||||
</span>
|
||||
</Link>
|
||||
<h2 className="text-gray-400 font-mono text-sm tracking-widest">IDENTITY_VERIFICATION_PROTOCOL</h2>
|
||||
</div>
|
||||
|
||||
<div className="bg-cyber-panel/40 backdrop-blur-md border border-white/10 p-8 rounded-lg shadow-2xl relative overflow-hidden group">
|
||||
{/* Border Gradient Animation */}
|
||||
<div className="absolute inset-0 border border-cyber-neon/0 group-hover:border-cyber-neon/50 transition-colors duration-500 rounded-lg pointer-events-none"></div>
|
||||
|
||||
<form onSubmit={handleLogin} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-mono text-cyber-neon">USER_ID</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
name="username"
|
||||
type="text"
|
||||
className="w-full bg-black/50 border border-gray-800 focus:border-cyber-neon text-white p-3 pl-10 rounded-sm outline-none transition-all font-mono"
|
||||
placeholder="Enter username"
|
||||
required
|
||||
/>
|
||||
<User className="absolute left-3 top-3 text-gray-500 w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-mono text-cyber-neon">ACCESS_KEY</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
className="w-full bg-black/50 border border-gray-800 focus:border-cyber-neon text-white p-3 pl-10 rounded-sm outline-none transition-all font-mono"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
<Lock className="absolute left-3 top-3 text-gray-500 w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-900/20 border border-red-500 text-red-500 p-3 rounded text-xs font-mono">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-cyber-neon text-black font-bold py-3 mt-4 hover:bg-white transition-colors flex items-center justify-center gap-2 font-mono uppercase tracking-wider"
|
||||
>
|
||||
{loading ? 'AUTHENTICATING...' : (
|
||||
<>
|
||||
INITIATE_SESSION <ArrowRight size={16} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 flex justify-between items-center text-xs font-mono text-gray-500">
|
||||
<Link href="/auth/register" className="hover:text-cyber-neon transition-colors">
|
||||
[ CREATE_NEW_IDENTITY ]
|
||||
</Link>
|
||||
<a href="#" className="hover:text-white transition-colors">
|
||||
FORGOT_KEY?
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 text-center">
|
||||
<p className="text-[10px] text-gray-700 font-mono">
|
||||
SECURE CONNECTION ESTABLISHED <br />
|
||||
ENCRYPTION: AES-256-GCM
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
130
pages/auth/register.tsx
Normal file
130
pages/auth/register.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { Hexagon, UploadCloud, Mail, Lock, User, ShieldCheck } from 'lucide-react';
|
||||
import Head from 'next/head';
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleRegister = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const username = formData.get('username') as string;
|
||||
const password = formData.get('password') as string;
|
||||
const confirmPassword = formData.get('confirmPassword') as string;
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// Also set REAL mode after successful registration
|
||||
localStorage.setItem('NEXUS_DATA_MODE', 'REAL');
|
||||
router.push('/');
|
||||
} else {
|
||||
setError(data.error || 'Registration failed');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Network error. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center relative overflow-hidden bg-[#020202]">
|
||||
<Head>
|
||||
<title>NEXUS_MAT.OS</title>
|
||||
</Head>
|
||||
|
||||
<div className="absolute inset-0 bg-[url('https://grainy-gradients.vercel.app/noise.svg')] opacity-20"></div>
|
||||
<div className="absolute bottom-0 right-0 w-[600px] h-[600px] bg-cyber-pink/5 rounded-full blur-[100px] pointer-events-none"></div>
|
||||
|
||||
<div className="w-full max-w-lg p-8 relative z-10">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-3xl font-mono font-bold text-white mb-2">JOIN_NETWORK</h1>
|
||||
<p className="text-xs text-gray-500 font-mono">ESTABLISH NEW NEURAL LINK</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-cyber-panel/40 backdrop-blur-md border border-white/10 p-8 rounded-lg shadow-2xl">
|
||||
<form onSubmit={handleRegister} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-mono text-gray-400">USERNAME</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
name="username"
|
||||
type="text"
|
||||
className="w-full bg-black/50 border border-gray-800 focus:border-cyber-pink text-white p-2.5 pl-8 rounded-sm outline-none font-mono text-sm"
|
||||
required
|
||||
/>
|
||||
<User className="absolute left-2.5 top-3 text-gray-600 w-3 h-3" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-mono text-gray-400">PASSWORD</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
className="w-full bg-black/50 border border-gray-800 focus:border-cyber-pink text-white p-2.5 pl-8 rounded-sm outline-none font-mono text-sm"
|
||||
required
|
||||
/>
|
||||
<Lock className="absolute left-2.5 top-3 text-gray-600 w-3 h-3" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-mono text-gray-400">CONFIRM_PASSWORD</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
className="w-full bg-black/50 border border-gray-800 focus:border-cyber-pink text-white p-2.5 pl-8 rounded-sm outline-none font-mono text-sm"
|
||||
required
|
||||
/>
|
||||
<ShieldCheck className="absolute left-2.5 top-3 text-gray-600 w-3 h-3" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-900/20 border border-red-500 text-red-500 p-3 rounded text-xs font-mono">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-cyber-pink text-white font-bold py-3 mt-4 hover:bg-white hover:text-black transition-colors font-mono uppercase tracking-wider shadow-[0_0_15px_rgba(255,0,85,0.3)]"
|
||||
>
|
||||
{loading ? 'PROCESSING...' : 'CREATE_ACCOUNT'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center text-xs font-mono text-gray-500">
|
||||
ALREADY LINKED? <Link href="/auth/login" className="text-cyber-pink hover:underline">ACCESS_TERMINAL</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
441
pages/console.tsx
Normal file
441
pages/console.tsx
Normal file
@@ -0,0 +1,441 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { DataService, setApiMode } from '../services/dataService';
|
||||
import { UserDTO, SystemConfig } from '../types';
|
||||
import { ShieldAlert, Database, Users, Terminal, LogOut, Save, RefreshCw, Power, Lock, Search } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import Head from 'next/head';
|
||||
|
||||
export default function AdminConsole() {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Dashboard State
|
||||
const [activeTab, setActiveTab] = useState<'USERS' | 'DB' | 'LOGS'>('USERS');
|
||||
const [users, setUsers] = useState<UserDTO[]>([]);
|
||||
const [config, setConfig] = useState<SystemConfig | null>(null);
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
const [currentMode, setCurrentMode] = useState<string>('MOCK');
|
||||
|
||||
// Fake Terminal Logs
|
||||
const logContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Client-side only init
|
||||
setCurrentMode(DataService.getMode());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
loadDashboardData();
|
||||
const cleanup = startLogStream();
|
||||
return cleanup;
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
|
||||
}
|
||||
}, [logs]);
|
||||
|
||||
const startLogStream = () => {
|
||||
if (DataService.getMode() === 'REAL') {
|
||||
setLogs(['[SYSTEM] Attempting to connect to live backend log stream...', '[WARN] Log streaming over WebSocket not fully implemented in Preview']);
|
||||
return () => { };
|
||||
}
|
||||
|
||||
const phrases = [
|
||||
"Connection pool optimized...",
|
||||
"User u_002 requested packet 0x4F...",
|
||||
"Analysing traffic pattern...",
|
||||
"Database heartbeat: STABLE",
|
||||
"WARNING: High latency on node US-EAST-4",
|
||||
"Backing up sector 7...",
|
||||
"New material indexed successfully."
|
||||
];
|
||||
const interval = setInterval(() => {
|
||||
const phrase = phrases[Math.floor(Math.random() * phrases.length)];
|
||||
const time = new Date().toISOString().split('T')[1].replace('Z', '');
|
||||
setLogs(prev => [...prev.slice(-20), `[${time}] ${phrase}`]);
|
||||
}, 2000);
|
||||
return () => clearInterval(interval);
|
||||
};
|
||||
|
||||
const loadDashboardData = async () => {
|
||||
try {
|
||||
const u = await DataService.getAllUsers();
|
||||
setUsers(u);
|
||||
const c = await DataService.getSystemConfig();
|
||||
setConfig(c);
|
||||
} catch (e) {
|
||||
setLogs(prev => [...prev, `[ERROR] Failed to fetch data: ${e}`]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
const success = await DataService.verifyAdmin(username, password);
|
||||
if (success) {
|
||||
setIsAuthenticated(true);
|
||||
setLogs(['[SYSTEM] Root access granted.', '[SYSTEM] Initializing admin daemon...']);
|
||||
} else {
|
||||
setError('ACCESS_DENIED // INVALID_CREDENTIALS');
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleConfigSave = async () => {
|
||||
if (!config) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await DataService.updateSystemConfig(config);
|
||||
setLogs(prev => [...prev, '[SYSTEM] Database configuration updated. Restarting services...']);
|
||||
alert("SYSTEM CONFIG UPDATED.");
|
||||
} catch (e) {
|
||||
alert("Failed to update config.");
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleToggleUser = async (uid: string) => {
|
||||
const updated = await DataService.toggleUserStatus(uid);
|
||||
if (updated) {
|
||||
setUsers(users.map(u => u.id === uid ? updated : u));
|
||||
setLogs(prev => [...prev, `[ADMIN] User ${updated.username} status changed to ${updated.status}`]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateRole = async (uid: string, role: 'USER' | 'CREATOR' | 'MANAGER' | 'ADMIN') => {
|
||||
try {
|
||||
const updated = await DataService.updateUserRole(uid, role as any);
|
||||
setUsers(prev => prev.map(u => u.id === uid ? updated : u));
|
||||
setLogs(prev => [...prev, `[ADMIN] Role of ${updated.username} -> ${updated.role}`]);
|
||||
} catch (e) {
|
||||
setLogs(prev => [...prev, `[ERROR] Failed to update role: ${e}`]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleModeSwitch = (mode: 'MOCK' | 'REAL') => {
|
||||
if (mode === 'REAL' && !confirm("WARNING: Switching to REAL mode will attempt to connect to a running MySQL backend at localhost:3000. \n\nContinue?")) {
|
||||
return;
|
||||
}
|
||||
setApiMode(mode);
|
||||
setCurrentMode(mode);
|
||||
};
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-red-600 font-mono flex items-center justify-center p-4 relative overflow-hidden">
|
||||
<Head>
|
||||
<title>NEXUS_MAT.OS</title>
|
||||
</Head>
|
||||
{/* Background Grid */}
|
||||
<div className="absolute inset-0 z-0 opacity-20 pointer-events-none"
|
||||
style={{ backgroundImage: 'linear-gradient(rgba(255, 0, 0, 0.1) 1px, transparent 1px), linear-gradient(90deg, rgba(255, 0, 0, 0.1) 1px, transparent 1px)', backgroundSize: '20px 20px' }}>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-md z-10 border border-red-900 bg-black/80 backdrop-blur-sm p-8 shadow-[0_0_50px_rgba(220,38,38,0.2)]">
|
||||
<div className="flex justify-center mb-8">
|
||||
<ShieldAlert size={64} className="animate-pulse" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-center mb-2 tracking-widest">NEXUS<span className="text-white">_CORE</span></h1>
|
||||
<p className="text-center text-xs text-red-800 mb-8">RESTRICTED AREA // AUTHORIZED PERSONNEL ONLY</p>
|
||||
|
||||
<form onSubmit={handleLogin} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs">IDENTIFIER</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
className="w-full bg-red-950/20 border border-red-900 p-3 text-red-500 focus:outline-none focus:border-red-500 transition-colors"
|
||||
placeholder="admin"
|
||||
/>
|
||||
<Lock className="absolute right-3 top-3 text-red-900" size={16} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs">KEY_PHRASE</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
className="w-full bg-red-950/20 border border-red-900 p-3 text-red-500 focus:outline-none focus:border-red-500 transition-colors"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-xs bg-red-950/50 border border-red-500 p-2 text-center animate-pulse">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
disabled={loading}
|
||||
className="w-full py-4 bg-red-700 hover:bg-red-600 text-black font-bold tracking-widest transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'AUTHENTICATING...' : 'ESTABLISH_CONNECTION'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-8 text-[10px] text-center text-red-900">
|
||||
SYSTEM_ID: 0x8842-ALPHA <br /> IP_LOGGED: 127.0.0.1
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#050000] text-red-500 font-mono flex flex-col">
|
||||
<Head>
|
||||
<title>NEXUS_MAT.OS</title>
|
||||
</Head>
|
||||
{/* Top Bar */}
|
||||
<header className="h-16 border-b border-red-900/50 bg-black/50 flex items-center justify-between px-6 backdrop-blur">
|
||||
<div className="flex items-center gap-4">
|
||||
<Terminal size={24} />
|
||||
<span className="text-xl font-bold tracking-tighter text-white">CORE<span className="text-red-600">.CONSOLE</span></span>
|
||||
<span className="px-2 py-0.5 text-[10px] border border-red-800 rounded bg-red-900/20 text-red-400">ADMIN_MODE</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`hidden md:flex items-center gap-2 text-xs ${currentMode === 'REAL' ? 'text-green-500' : 'text-yellow-500'}`}>
|
||||
<span className="animate-pulse">●</span> {currentMode === 'REAL' ? 'LIVE_DATABASE' : 'SIMULATION_MODE'}
|
||||
</div>
|
||||
<button onClick={() => window.location.href = '/'} className="p-2 hover:bg-red-900/20 rounded border border-transparent hover:border-red-900 transition-colors" title="Exit to App">
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-64 border-r border-red-900/30 bg-red-950/5 hidden md:flex flex-col">
|
||||
<nav className="p-4 space-y-2">
|
||||
<button
|
||||
onClick={() => setActiveTab('USERS')}
|
||||
className={`w-full flex items-center gap-3 p-3 text-sm transition-all ${activeTab === 'USERS' ? 'bg-red-900/20 border-l-2 border-red-500 text-white' : 'text-red-800 hover:text-red-400'}`}
|
||||
>
|
||||
<Users size={18} /> USER_REGISTRY
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('DB')}
|
||||
className={`w-full flex items-center gap-3 p-3 text-sm transition-all ${activeTab === 'DB' ? 'bg-red-900/20 border-l-2 border-red-500 text-white' : 'text-red-800 hover:text-red-400'}`}
|
||||
>
|
||||
<Database size={18} /> DATABASE_CONFIG
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('LOGS')}
|
||||
className={`w-full flex items-center gap-3 p-3 text-sm transition-all ${activeTab === 'LOGS' ? 'bg-red-900/20 border-l-2 border-red-500 text-white' : 'text-red-800 hover:text-red-400'}`}
|
||||
>
|
||||
<Terminal size={18} /> SYSTEM_LOGS
|
||||
</button>
|
||||
</nav>
|
||||
<div className="mt-auto p-4 border-t border-red-900/30">
|
||||
<div className="text-[10px] text-red-900">
|
||||
SERVER_UPTIME: 489h 22m<br />
|
||||
MEMORY: 64TB / 128TB
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 overflow-y-auto p-8 relative">
|
||||
{/* Background Decoration */}
|
||||
<div className="absolute top-0 right-0 p-10 opacity-10 pointer-events-none">
|
||||
<Database size={200} />
|
||||
</div>
|
||||
|
||||
{activeTab === 'USERS' && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-end border-b border-red-900/30 pb-4">
|
||||
<h2 className="text-2xl font-bold text-white">USER_DATABASE</h2>
|
||||
<div className="flex gap-2">
|
||||
<input placeholder="SEARCH_ID..." className="bg-black border border-red-900 p-1 text-xs text-red-500 w-40 focus:outline-none" />
|
||||
<button className="p-1 bg-red-900/20 border border-red-900"><Search size={14} /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="text-xs text-red-700 uppercase tracking-wider bg-red-950/10">
|
||||
<tr>
|
||||
<th className="p-3">AVATAR</th>
|
||||
<th className="p-3">IDENTIFIER</th>
|
||||
<th className="p-3">ROLE</th>
|
||||
<th className="p-3">STATUS</th>
|
||||
<th className="p-3">CREATED</th>
|
||||
<th className="p-3 text-right">ACTIONS</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-red-900/20">
|
||||
{users.map(user => (
|
||||
<tr key={user.id} className="hover:bg-red-900/5 transition-colors group">
|
||||
<td className="p-3">
|
||||
<Image src={user.avatarUrl} alt="avatar" width={32} height={32} className="w-8 h-8 rounded-sm grayscale group-hover:grayscale-0 transition-all border border-red-900/50" />
|
||||
</td>
|
||||
<td className="p-3 font-bold text-gray-300">{user.username}<br /><span className="text-[10px] text-red-800">{user.id}</span></td>
|
||||
<td className="p-3 text-xs">
|
||||
<select
|
||||
value={user.role}
|
||||
onChange={e => handleUpdateRole(user.id, e.target.value as any)}
|
||||
className="bg-black border border-red-900 text-red-500 text-xs p-1"
|
||||
>
|
||||
<option value="USER">USER</option>
|
||||
<option value="CREATOR">CREATOR</option>
|
||||
<option value="MANAGER">MANAGER</option>
|
||||
<option value="ADMIN">ADMIN</option>
|
||||
</select>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<span className={`px-2 py-0.5 text-[10px] border ${user.status === 'ACTIVE' ? 'border-green-900 text-green-500' : 'border-red-500 text-red-500 animate-pulse'}`}>
|
||||
{user.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-3 text-xs text-red-800">{new Date(user.createdAt).toLocaleDateString()}</td>
|
||||
<td className="p-3 text-right">
|
||||
<button
|
||||
onClick={() => handleToggleUser(user.id)}
|
||||
className="px-3 py-1 border border-red-900 hover:bg-red-500 hover:text-black transition-colors text-xs"
|
||||
>
|
||||
{user.status === 'ACTIVE' ? 'BAN_USER' : 'RESTORE'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'DB' && config && (
|
||||
<div className="max-w-2xl space-y-8">
|
||||
<h2 className="text-2xl font-bold text-white border-b border-red-900/30 pb-4 flex items-center justify-between">
|
||||
MYSQL_CONFIGURATION
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-xs font-normal text-gray-400">DATA_SOURCE_MODE:</span>
|
||||
<div className="flex border border-red-900 rounded overflow-hidden">
|
||||
<button
|
||||
onClick={() => handleModeSwitch('MOCK')}
|
||||
className={`px-3 py-1 text-xs transition-colors ${currentMode === 'MOCK' ? 'bg-red-600 text-black font-bold' : 'hover:bg-red-900/20 text-red-500'}`}
|
||||
>
|
||||
SIMULATION
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleModeSwitch('REAL')}
|
||||
className={`px-3 py-1 text-xs transition-colors ${currentMode === 'REAL' ? 'bg-red-600 text-black font-bold' : 'hover:bg-red-900/20 text-red-500'}`}
|
||||
>
|
||||
LIVE_MYSQL
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</h2>
|
||||
|
||||
<div className={`grid grid-cols-1 md:grid-cols-2 gap-6 ${currentMode === 'MOCK' ? 'opacity-50 pointer-events-none grayscale' : ''}`}>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-red-700">HOST_ADDRESS</label>
|
||||
<input
|
||||
value={config.dbHost}
|
||||
onChange={e => setConfig({ ...config, dbHost: e.target.value })}
|
||||
className="w-full bg-black border border-red-900 p-3 text-white focus:border-red-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-red-700">PORT</label>
|
||||
<input
|
||||
value={config.dbPort}
|
||||
onChange={e => setConfig({ ...config, dbPort: e.target.value })}
|
||||
className="w-full bg-black border border-red-900 p-3 text-white focus:border-red-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-red-700">ROOT_USER</label>
|
||||
<input
|
||||
value={config.dbUser}
|
||||
onChange={e => setConfig({ ...config, dbUser: e.target.value })}
|
||||
className="w-full bg-black border border-red-900 p-3 text-white focus:border-red-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-red-700">ENCRYPTED_PASS</label>
|
||||
<input
|
||||
type="password"
|
||||
value={config.dbPass}
|
||||
onChange={e => setConfig({ ...config, dbPass: e.target.value })}
|
||||
className="w-full bg-black border border-red-900 p-3 text-white focus:border-red-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-red-700">DATABASE_NAME</label>
|
||||
<input
|
||||
value={config.dbName}
|
||||
onChange={e => setConfig({ ...config, dbName: e.target.value })}
|
||||
className="w-full bg-black border border-red-900 p-3 text-white focus:border-red-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-red-700">MAX_UPLOAD_MB</label>
|
||||
<input
|
||||
value={config.maxUploadMB}
|
||||
onChange={e => setConfig({ ...config, maxUploadMB: Number(e.target.value) || 0 })}
|
||||
className="w-full bg-black border border-red-900 p-3 text-white focus:border-red-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentMode === 'MOCK' && (
|
||||
<div className="p-4 border border-yellow-800 bg-yellow-900/10 text-yellow-600 text-xs font-mono">
|
||||
[INFO] Currently running in SIMULATION MODE.
|
||||
<br />Configuration editing is disabled.
|
||||
<br />Switch to LIVE_MYSQL to configure connection string.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 pt-6 border-t border-red-900/30">
|
||||
<button
|
||||
onClick={handleConfigSave}
|
||||
disabled={loading || currentMode === 'MOCK'}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-red-600 text-black font-bold hover:bg-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? <RefreshCw className="animate-spin" size={18} /> : <Save size={18} />}
|
||||
COMMIT_CHANGES
|
||||
</button>
|
||||
<button className="px-6 py-3 border border-red-900 text-red-500 hover:bg-red-900/20 transition-colors flex items-center gap-2">
|
||||
<Power size={18} /> SHUTDOWN_DB
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'LOGS' && (
|
||||
<div className="h-full flex flex-col">
|
||||
<h2 className="text-2xl font-bold text-white border-b border-red-900/30 pb-4 mb-4">KERNEL_LOGS</h2>
|
||||
<div
|
||||
ref={logContainerRef}
|
||||
className="flex-1 bg-black/50 border border-red-900/50 p-4 font-mono text-xs overflow-y-auto font-light"
|
||||
>
|
||||
{logs.map((log, i) => (
|
||||
<div key={i} className="mb-1 text-red-400 hover:text-white transition-colors cursor-default">
|
||||
<span className="opacity-50 mr-2">{i.toString().padStart(4, '0')}</span>
|
||||
{log}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
162
pages/index.tsx
Normal file
162
pages/index.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import Head from 'next/head';
|
||||
import { Navbar } from '../components/Navbar';
|
||||
import { MaterialCard } from '../components/MaterialCard';
|
||||
import { MaterialDetail } from '../components/MaterialDetail';
|
||||
import { CreateModal } from '../components/CreateModal';
|
||||
import { ProfileModal } from '../components/ProfileModal';
|
||||
import { DataService } from '../services/dataService';
|
||||
import { MaterialDTO, UserDTO, MaterialType } from '../types';
|
||||
import { Filter, Grid } from 'lucide-react';
|
||||
|
||||
export default function Home() {
|
||||
const [materials, setMaterials] = useState<MaterialDTO[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 20;
|
||||
const [total, setTotal] = useState(0);
|
||||
const [hasNext, setHasNext] = useState(false);
|
||||
const [currentUser, setCurrentUser] = useState<UserDTO | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [isProfileOpen, setIsProfileOpen] = useState(false);
|
||||
const [filter, setFilter] = useState<'ALL' | 'CODE' | 'VIDEO' | 'ASSET_ZIP'>('ALL');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const loadMaterials = useCallback(async () => {
|
||||
try {
|
||||
const filterKey: MaterialType | 'ALL' = filter === 'ALL' ? 'ALL' : filter as any;
|
||||
const result = await DataService.getMaterials(page, pageSize, filterKey, searchQuery);
|
||||
setMaterials(result.items);
|
||||
setTotal(result.total);
|
||||
setHasNext(result.hasNext);
|
||||
} catch (e) {
|
||||
console.error("Failed to load materials", e);
|
||||
}
|
||||
}, [page, filter, searchQuery]);
|
||||
|
||||
const refreshUser = useCallback(async () => {
|
||||
const user = await DataService.getCurrentUser();
|
||||
setCurrentUser(user);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const run = async () => {
|
||||
await refreshUser();
|
||||
await loadMaterials();
|
||||
};
|
||||
run();
|
||||
}, [loadMaterials, refreshUser]);
|
||||
|
||||
const filteredMaterials = materials;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen pb-20">
|
||||
<Head>
|
||||
<title>NEXUS_MAT.OS</title>
|
||||
</Head>
|
||||
<Navbar
|
||||
user={currentUser}
|
||||
onOpenCreate={() => setIsCreateOpen(true)}
|
||||
onProfileClick={() => setIsProfileOpen(true)}
|
||||
searchQuery={searchQuery}
|
||||
onSearch={(q) => { setSearchQuery(q); setPage(1); }}
|
||||
/>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-8">
|
||||
|
||||
{/* Header / Filter Section */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between mb-12 gap-6">
|
||||
<div>
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-white font-mono tracking-tighter mb-2">
|
||||
GRID_ACCESS
|
||||
</h1>
|
||||
<p className="text-gray-500 font-mono text-sm">
|
||||
INDEXING {total} RESOURCES FROM THE NETWORK
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 bg-cyber-panel/30 p-2 rounded-lg border border-white/5 backdrop-blur-sm">
|
||||
<Filter size={16} className="text-cyber-neon ml-2" />
|
||||
<div className="flex gap-2">
|
||||
{(['ALL', 'CODE', 'VIDEO', 'ASSET_ZIP'] as const).map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => { setFilter(f); setPage(1); }}
|
||||
className={`px-3 py-1 text-xs font-mono rounded transition-all ${filter === f
|
||||
? 'bg-cyber-neon text-black font-bold'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{f}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="w-[1px] h-6 bg-gray-700 mx-2"></div>
|
||||
<Grid size={16} className="text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* The Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredMaterials.map(m => (
|
||||
<MaterialCard
|
||||
key={m.id}
|
||||
material={m}
|
||||
onClick={(id) => setSelectedId(id)}
|
||||
currentUser={currentUser}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="mt-8 flex items-center justify-center gap-4">
|
||||
<button onClick={() => setPage(p => Math.max(1, p - 1))} className="px-3 py-2 border border-white/10 text-gray-400 hover:text-white">Prev</button>
|
||||
<span className="text-xs font-mono text-gray-500">PAGE {page} / {Math.max(1, Math.ceil(total / pageSize))}</span>
|
||||
<button
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
disabled={!hasNext}
|
||||
className="px-3 py-2 border border-white/10 text-gray-400 hover:text-white disabled:opacity-50"
|
||||
>Next</button>
|
||||
</div>
|
||||
|
||||
{/* Empty State */}
|
||||
{filteredMaterials.length === 0 && (
|
||||
<div className="text-center py-20 border border-dashed border-gray-800 rounded-xl">
|
||||
<p className="text-gray-600 font-mono">NO DATA FOUND IN SECTOR.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</main>
|
||||
|
||||
{/* Modals */}
|
||||
{selectedId && (
|
||||
<MaterialDetail
|
||||
id={selectedId}
|
||||
onClose={() => setSelectedId(null)}
|
||||
currentUser={currentUser}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isCreateOpen && (
|
||||
<CreateModal
|
||||
onClose={() => setIsCreateOpen(false)}
|
||||
onSuccess={() => {
|
||||
setIsCreateOpen(false);
|
||||
loadMaterials();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isProfileOpen && currentUser && (
|
||||
<ProfileModal
|
||||
user={currentUser}
|
||||
onClose={() => setIsProfileOpen(false)}
|
||||
onUpdate={refreshUser}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Footer Decoration */}
|
||||
<footer className="fixed bottom-0 left-0 w-full h-1 bg-gradient-to-r from-cyber-neon via-purple-600 to-cyber-blue opacity-50"></footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user