439 lines
15 KiB
TypeScript
439 lines
15 KiB
TypeScript
|
|
import { MaterialDTO, MaterialType, UserDTO, UserRole, CommentDTO, SystemConfig, PaginationResult } from '../types';
|
|
|
|
// --- ENTERPRISE DATA LAYER ---
|
|
// This service acts as the Facade Pattern, shielding the UI from the complexity
|
|
// of switching between a mock environment (for dev/demo) and the real backend.
|
|
|
|
const STORAGE_KEY_MODE = 'NEXUS_DATA_MODE';
|
|
|
|
const getMode = (): 'MOCK' | 'REAL' => {
|
|
// Default to MOCK for the browser demo environment
|
|
return (localStorage.getItem(STORAGE_KEY_MODE) as 'MOCK' | 'REAL') || 'MOCK';
|
|
};
|
|
|
|
export const setApiMode = (mode: 'MOCK' | 'REAL') => {
|
|
localStorage.setItem(STORAGE_KEY_MODE, mode);
|
|
// Reload to apply changes cleanly across the app
|
|
window.location.reload();
|
|
};
|
|
|
|
// --- MOCK DATABASE STATE ---
|
|
const CURRENT_USER: UserDTO = {
|
|
id: 'u_001',
|
|
username: 'Neo_Architect',
|
|
avatarUrl: 'https://picsum.photos/seed/neo/200/200',
|
|
role: UserRole.CREATOR,
|
|
status: 'ACTIVE',
|
|
createdAt: new Date(2023, 10, 15).toISOString(),
|
|
lastLogin: new Date().toISOString()
|
|
};
|
|
|
|
const ALL_USERS: UserDTO[] = [
|
|
CURRENT_USER,
|
|
{
|
|
id: 'u_002',
|
|
username: 'Visual_Drift',
|
|
avatarUrl: 'https://picsum.photos/seed/drift/200/200',
|
|
role: UserRole.CREATOR,
|
|
status: 'ACTIVE',
|
|
createdAt: new Date(2023, 11, 1).toISOString(),
|
|
lastLogin: new Date().toISOString()
|
|
},
|
|
{
|
|
id: 'u_003',
|
|
username: 'Poly_Master',
|
|
avatarUrl: 'https://picsum.photos/seed/poly/200/200',
|
|
role: UserRole.USER,
|
|
status: 'FLAGGED',
|
|
createdAt: new Date(2024, 0, 10).toISOString(),
|
|
lastLogin: new Date().toISOString()
|
|
},
|
|
{
|
|
id: 'u_004',
|
|
username: 'Script_Kiddie',
|
|
avatarUrl: 'https://picsum.photos/seed/kiddie/200/200',
|
|
role: UserRole.USER,
|
|
status: 'BANNED',
|
|
createdAt: new Date(2024, 1, 20).toISOString(),
|
|
lastLogin: new Date(2024, 2, 1).toISOString()
|
|
}
|
|
];
|
|
|
|
const MOCK_MATERIALS: MaterialDTO[] = [
|
|
{
|
|
id: 'm_001',
|
|
title: 'Cyberpunk HUD Interface React',
|
|
description: 'A complete React component set for building sci-fi interfaces. Includes glitch effects and holographic transitions.',
|
|
type: MaterialType.CODE,
|
|
codeSnippet: `const CyberButton = ({ children }) => (
|
|
<button className="border border-neon-500 text-neon-500 hover:bg-neon-500 hover:text-black transition-all duration-300 uppercase tracking-widest px-6 py-2">
|
|
{children}
|
|
</button>
|
|
);`,
|
|
language: 'tsx',
|
|
author: CURRENT_USER,
|
|
stats: { views: 1204, downloads: 450, favorites: 89 },
|
|
tags: ['react', 'ui', 'cyberpunk', 'animation'],
|
|
createdAt: new Date(Date.now() - 86400000 * 2).toISOString(),
|
|
comments: []
|
|
},
|
|
{
|
|
id: 'm_002',
|
|
title: 'Neon City Ambience Loop',
|
|
description: '4K loop of a futuristic city rain scene. Perfect for background assets.',
|
|
type: MaterialType.VIDEO,
|
|
contentUrl: 'https://media.w3.org/2010/05/sintel/trailer.mp4', // Placeholder video
|
|
author: ALL_USERS[1],
|
|
stats: { views: 5320, downloads: 1200, favorites: 432 },
|
|
tags: ['video', 'loop', 'background', '4k'],
|
|
createdAt: new Date(Date.now() - 86400000 * 5).toISOString(),
|
|
comments: [
|
|
{
|
|
id: 'c_1',
|
|
content: 'The lighting on this is insane!',
|
|
author: CURRENT_USER,
|
|
createdAt: new Date().toISOString()
|
|
}
|
|
]
|
|
},
|
|
{
|
|
id: 'm_003',
|
|
title: 'Industrial 3D Assets Pack',
|
|
description: 'Low-poly mechanical parts for game dev. GLB format + Textures.',
|
|
type: MaterialType.ASSET_ZIP,
|
|
contentUrl: '#',
|
|
author: ALL_USERS[2],
|
|
stats: { views: 800, downloads: 120, favorites: 45 },
|
|
tags: ['3d', 'blender', 'game-assets'],
|
|
createdAt: new Date(Date.now() - 86400000 * 1).toISOString(),
|
|
comments: []
|
|
}
|
|
];
|
|
|
|
// Generate additional code materials for testing (total 40)
|
|
if (MOCK_MATERIALS.length < 40) {
|
|
const base = MOCK_MATERIALS.length;
|
|
const types = [MaterialType.CODE, MaterialType.ASSET_ZIP, MaterialType.VIDEO];
|
|
for (let i = base; i < 40; i++) {
|
|
const id = `m_${(i + 1).toString().padStart(3, '0')}`;
|
|
const author = ALL_USERS[i % ALL_USERS.length];
|
|
const t = types[i % types.length];
|
|
const createdAt = new Date(Date.now() - 86400000 * (i % 10)).toISOString();
|
|
const stats = { views: Math.floor(Math.random() * 1000), downloads: Math.floor(Math.random() * 200), favorites: Math.floor(Math.random() * 50) };
|
|
const common = {
|
|
id,
|
|
title: t === MaterialType.CODE ? `Test Code Snippet #${i + 1}` : t === MaterialType.ASSET_ZIP ? `Assets Pack #${i + 1}` : `Demo Video #${i + 1}`,
|
|
description: 'Auto-generated demo material.',
|
|
type: t,
|
|
author,
|
|
stats,
|
|
tags: t === MaterialType.CODE ? ['test', 'code'] : t === MaterialType.ASSET_ZIP ? ['assets'] : ['video'],
|
|
createdAt,
|
|
comments: [] as any[]
|
|
};
|
|
if (t === MaterialType.CODE) {
|
|
MOCK_MATERIALS.push({
|
|
...common,
|
|
codeSnippet: `function test${i}(){return ${i};}`,
|
|
language: 'ts'
|
|
});
|
|
} else if (t === MaterialType.ASSET_ZIP) {
|
|
MOCK_MATERIALS.push({
|
|
...common,
|
|
contentUrl: '#'
|
|
});
|
|
} else {
|
|
MOCK_MATERIALS.push({
|
|
...common,
|
|
contentUrl: 'https://media.w3.org/2010/05/sintel/trailer.mp4'
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
let SYSTEM_CONFIG: SystemConfig = {
|
|
dbHost: '127.0.0.1',
|
|
dbPort: '3306',
|
|
dbUser: 'root',
|
|
dbPass: 'admin123',
|
|
dbName: 'nexus_db',
|
|
maintenanceMode: false,
|
|
maxUploadMB: 3
|
|
};
|
|
|
|
// Helper to call backend API in REAL mode
|
|
async function apiCall<T>(endpoint: string, options?: RequestInit): Promise<T> {
|
|
try {
|
|
const res = await fetch(`/api/v1${endpoint}`, {
|
|
...options,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...options?.headers
|
|
}
|
|
});
|
|
|
|
const json = await res.json();
|
|
|
|
if (!res.ok) {
|
|
// Return error as part of response structure
|
|
throw new Error(json.error || res.statusText || 'Request failed');
|
|
}
|
|
|
|
return json.data; // Assuming ApiResponse wrapper
|
|
} catch (e) {
|
|
console.error("API Call Failed:", e);
|
|
throw e; // Re-throw to be caught by caller
|
|
}
|
|
}
|
|
|
|
// --- SERVICE METHODS ---
|
|
|
|
export const DataService = {
|
|
|
|
getMode,
|
|
setApiMode,
|
|
|
|
// --- CLIENT SIDE ---
|
|
getCurrentUser: async (): Promise<UserDTO | null> => {
|
|
if (getMode() === 'REAL') {
|
|
try {
|
|
return await apiCall<UserDTO>('/auth/me');
|
|
} catch (error) {
|
|
// User not logged in or token expired, return null
|
|
console.log('User not authenticated');
|
|
return null;
|
|
}
|
|
}
|
|
return new Promise(resolve => setTimeout(() => resolve(CURRENT_USER), 500));
|
|
},
|
|
|
|
updateUserProfile: async (userId: string, data: Partial<UserDTO>): Promise<UserDTO> => {
|
|
if (getMode() === 'REAL') return apiCall<UserDTO>('/users/me', { method: 'PATCH', body: JSON.stringify(data) });
|
|
|
|
return new Promise(resolve => {
|
|
if (CURRENT_USER.id === userId) {
|
|
if (data.username) CURRENT_USER.username = data.username;
|
|
if (data.avatarUrl) CURRENT_USER.avatarUrl = data.avatarUrl;
|
|
}
|
|
setTimeout(() => resolve({ ...CURRENT_USER }), 800);
|
|
});
|
|
},
|
|
|
|
getMaterials: async (page = 1, limit = 20, type?: MaterialType | 'ALL', search?: string): Promise<PaginationResult<MaterialDTO>> => {
|
|
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
|
if (type && type !== 'ALL') params.append('type', String(type));
|
|
if (search && search.trim()) params.append('q', search.trim());
|
|
if (getMode() === 'REAL') return apiCall<PaginationResult<MaterialDTO>>(`/materials?${params.toString()}`);
|
|
|
|
let source = type && type !== 'ALL' ? MOCK_MATERIALS.filter(m => m.type === type) : MOCK_MATERIALS;
|
|
if (search && search.trim()) {
|
|
const q = search.trim().toLowerCase();
|
|
source = source.filter(m => {
|
|
const inTitle = m.title.toLowerCase().includes(q);
|
|
const inDesc = m.description.toLowerCase().includes(q);
|
|
const inTags = m.tags.some(t => t.toLowerCase().includes(q));
|
|
return inTitle || inDesc || inTags;
|
|
});
|
|
}
|
|
const start = (page - 1) * limit;
|
|
const end = start + limit;
|
|
const items = source.slice(start, end);
|
|
const total = source.length;
|
|
const hasNext = page * limit < total;
|
|
return new Promise(resolve => setTimeout(() => resolve({ items, total, page, limit, hasNext }), 400));
|
|
},
|
|
|
|
getUserMaterials: async (page = 1, limit = 20): Promise<PaginationResult<MaterialDTO>> => {
|
|
if (getMode() === 'REAL') return apiCall<PaginationResult<MaterialDTO>>(`/users/me/materials?page=${page}&limit=${limit}`);
|
|
const userItems = MOCK_MATERIALS.filter(m => m.author.id === CURRENT_USER.id);
|
|
const start = (page - 1) * limit;
|
|
const end = start + limit;
|
|
const items = userItems.slice(start, end);
|
|
const total = userItems.length;
|
|
const hasNext = page * limit < total;
|
|
return new Promise(resolve => setTimeout(() => resolve({ items, total, page, limit, hasNext }), 300));
|
|
},
|
|
|
|
getUserFavorites: async (page = 1, limit = 20): Promise<PaginationResult<MaterialDTO>> => {
|
|
if (getMode() === 'REAL') return apiCall<PaginationResult<MaterialDTO>>(`/users/me/favorites?page=${page}&limit=${limit}`);
|
|
const favItems = MOCK_MATERIALS.filter(m => m.stats.favorites > 0);
|
|
const start = (page - 1) * limit;
|
|
const end = start + limit;
|
|
const items = favItems.slice(start, end);
|
|
const total = favItems.length;
|
|
const hasNext = page * limit < total;
|
|
return new Promise(resolve => setTimeout(() => resolve({ items, total, page, limit, hasNext }), 300));
|
|
},
|
|
|
|
getMaterialById: async (id: string): Promise<MaterialDTO | undefined> => {
|
|
if (getMode() === 'REAL') return apiCall<MaterialDTO>(`/materials/${id}`);
|
|
|
|
return new Promise(resolve => {
|
|
const item = MOCK_MATERIALS.find(m => m.id === id);
|
|
setTimeout(() => resolve(item), 400);
|
|
});
|
|
},
|
|
|
|
createMaterial: async (data: Partial<MaterialDTO>): Promise<MaterialDTO> => {
|
|
if (getMode() === 'REAL') return apiCall<MaterialDTO>('/materials', { method: 'POST', body: JSON.stringify(data) });
|
|
|
|
return new Promise(resolve => {
|
|
const newMaterial: MaterialDTO = {
|
|
id: `m_${Date.now()}`,
|
|
title: data.title || 'Untitled',
|
|
description: data.description || '',
|
|
type: data.type || MaterialType.CODE,
|
|
codeSnippet: data.codeSnippet,
|
|
contentUrl: data.contentUrl,
|
|
language: data.language || 'text',
|
|
author: CURRENT_USER,
|
|
stats: { views: 0, downloads: 0, favorites: 0 },
|
|
tags: data.tags || [],
|
|
createdAt: new Date().toISOString(),
|
|
comments: []
|
|
};
|
|
MOCK_MATERIALS.unshift(newMaterial);
|
|
setTimeout(() => resolve(newMaterial), 1000);
|
|
});
|
|
},
|
|
|
|
deleteMaterial: async (id: string): Promise<boolean> => {
|
|
if (getMode() === 'REAL') {
|
|
await apiCall(`/materials/${id}`, { method: 'DELETE' });
|
|
return true;
|
|
}
|
|
|
|
const idx = MOCK_MATERIALS.findIndex(m => m.id === id);
|
|
if (idx > -1) {
|
|
MOCK_MATERIALS.splice(idx, 1);
|
|
return true;
|
|
}
|
|
return false;
|
|
},
|
|
|
|
addComment: async (materialId: string, content: string): Promise<CommentDTO> => {
|
|
if (getMode() === 'REAL') return apiCall<CommentDTO>(`/materials/${materialId}/comments`, { method: 'POST', body: JSON.stringify({ content }) });
|
|
|
|
const material = MOCK_MATERIALS.find(m => m.id === materialId);
|
|
if (!material) throw new Error('Material not found');
|
|
|
|
const newComment: CommentDTO = {
|
|
id: `c_${Date.now()}`,
|
|
content,
|
|
author: CURRENT_USER,
|
|
createdAt: new Date().toISOString()
|
|
};
|
|
|
|
material.comments.push(newComment);
|
|
return newComment;
|
|
},
|
|
|
|
toggleFavorite: async (materialId: string): Promise<number> => {
|
|
if (getMode() === 'REAL') {
|
|
const res = await apiCall<{ favorites: number }>(`/materials/${materialId}/favorite`, { method: 'POST' });
|
|
return res.favorites;
|
|
}
|
|
|
|
const material = MOCK_MATERIALS.find(m => m.id === materialId);
|
|
if (material) {
|
|
// Toggle logic simulation
|
|
material.stats.favorites += 1;
|
|
return material.stats.favorites;
|
|
}
|
|
return 0;
|
|
},
|
|
|
|
uploadZip: async (file: File, meta?: { title?: string; description?: string; tags?: string[] }) => {
|
|
const fd = new FormData();
|
|
fd.append('file', file);
|
|
if (meta?.title) fd.append('title', meta.title);
|
|
if (meta?.description) fd.append('description', meta.description);
|
|
if (meta?.tags) fd.append('tags', meta.tags.join(','));
|
|
const res = await fetch('/api/v1/materials/upload-zip', { method: 'POST', body: fd });
|
|
const ct = res.headers.get('content-type') || '';
|
|
if (!ct.includes('application/json')) {
|
|
const text = await res.text();
|
|
throw new Error(text || `Upload failed (${res.status})`);
|
|
}
|
|
const json = await res.json();
|
|
if (!res.ok || !json.success) throw new Error(json.error || `Upload failed (${res.status})`);
|
|
return json.data as MaterialDTO;
|
|
},
|
|
|
|
uploadVideo: async (file: File, meta?: { title?: string; description?: string; tags?: string[] }) => {
|
|
const fd = new FormData();
|
|
fd.append('file', file);
|
|
if (meta?.title) fd.append('title', meta.title);
|
|
if (meta?.description) fd.append('description', meta.description);
|
|
if (meta?.tags) fd.append('tags', meta.tags.join(','));
|
|
const res = await fetch('/api/v1/materials/upload-video', { method: 'POST', body: fd });
|
|
const ct = res.headers.get('content-type') || '';
|
|
if (!ct.includes('application/json')) {
|
|
const text = await res.text();
|
|
throw new Error(text || `Upload failed (${res.status})`);
|
|
}
|
|
const json = await res.json();
|
|
if (!res.ok || !json.success) throw new Error(json.error || `Upload failed (${res.status})`);
|
|
return json.data as MaterialDTO;
|
|
},
|
|
|
|
// --- ADMIN SIDE ---
|
|
verifyAdmin: async (u: string, p: string): Promise<boolean> => {
|
|
// Admin login is usually separate or special
|
|
return new Promise(resolve => {
|
|
setTimeout(() => {
|
|
resolve(u === 'admin' && p === 'wx1998WX');
|
|
}, 1000);
|
|
});
|
|
},
|
|
|
|
getAllUsers: async (): Promise<UserDTO[]> => {
|
|
if (getMode() === 'REAL') return apiCall<UserDTO[]>('/admin/users');
|
|
return new Promise(resolve => setTimeout(() => resolve(ALL_USERS), 600));
|
|
},
|
|
|
|
updateUserRole: async (userId: string, role: UserRole): Promise<UserDTO> => {
|
|
if (getMode() === 'REAL') return apiCall<UserDTO>(`/admin/users/${userId}/role`, { method: 'POST', body: JSON.stringify({ role }) });
|
|
const u = ALL_USERS.find(user => user.id === userId);
|
|
if (!u) throw new Error('User not found');
|
|
u.role = role;
|
|
return new Promise(resolve => setTimeout(() => resolve({ ...u }), 400));
|
|
},
|
|
|
|
getSystemConfig: async (): Promise<SystemConfig> => {
|
|
if (getMode() === 'REAL') return apiCall<SystemConfig>('/admin/config');
|
|
return SYSTEM_CONFIG;
|
|
},
|
|
|
|
updateSystemConfig: async (config: SystemConfig): Promise<void> => {
|
|
if (getMode() === 'REAL') {
|
|
await apiCall('/admin/config', {
|
|
method: 'PUT',
|
|
body: JSON.stringify({
|
|
maxUploadMB: config.maxUploadMB,
|
|
uploadDir: 'data',
|
|
dbHost: config.dbHost,
|
|
dbPort: config.dbPort,
|
|
dbUser: config.dbUser,
|
|
dbPass: config.dbPass,
|
|
dbName: config.dbName,
|
|
})
|
|
});
|
|
return;
|
|
}
|
|
SYSTEM_CONFIG = config;
|
|
return new Promise(resolve => setTimeout(resolve, 1500));
|
|
},
|
|
|
|
toggleUserStatus: async (userId: string): Promise<UserDTO | undefined> => {
|
|
if (getMode() === 'REAL') return apiCall<UserDTO>(`/admin/users/${userId}/toggle-status`, { method: 'POST' });
|
|
|
|
const u = ALL_USERS.find(user => user.id === userId);
|
|
if (u) {
|
|
u.status = u.status === 'ACTIVE' ? 'BANNED' : 'ACTIVE';
|
|
return u;
|
|
}
|
|
}
|
|
};
|