asset_recommendation
This commit is contained in:
parent
c0c0e1a327
commit
9e4c9cc323
@ -40,6 +40,11 @@ model User {
|
||||
partnerGroup String?
|
||||
assignedNdaId String?
|
||||
assignedMsaId String?
|
||||
website String?
|
||||
sector String?
|
||||
companySize String?
|
||||
defaultTheme String? @default("dark")
|
||||
companyName String?
|
||||
assignedNda LegalDocument? @relation("AssignedNda", fields: [assignedNdaId], references: [id], onDelete: SetNull)
|
||||
assignedMsa LegalDocument? @relation("AssignedMsa", fields: [assignedMsaId], references: [id], onDelete: SetNull)
|
||||
organization Organization? @relation(fields: [organizationId], references: [id])
|
||||
@ -150,20 +155,6 @@ model AuditLog {
|
||||
actor User @relation(fields: [actorId], references: [id])
|
||||
}
|
||||
|
||||
model BlogPost {
|
||||
id String @id @default(uuid())
|
||||
title String
|
||||
content String @db.Text
|
||||
author String
|
||||
publishDate String
|
||||
thumbnailUrl String?
|
||||
readTime String?
|
||||
tags String[]
|
||||
status String @default("draft") // draft, published
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model AssetGroup {
|
||||
id String @id @default(uuid())
|
||||
name String @unique
|
||||
|
||||
@ -113,37 +113,6 @@ async function seed() {
|
||||
} else {
|
||||
console.log(`Partner (Approved) already exists: ${activeEmail}`);
|
||||
}
|
||||
|
||||
// 7. Seed Blog Posts
|
||||
const blogCount = await prisma.blogPost.count();
|
||||
if (blogCount === 0) {
|
||||
await prisma.blogPost.createMany({
|
||||
data: [
|
||||
{
|
||||
title: "Unlocking Ultra-Low Latency: Synthesis of RISC-V in Edge Devices",
|
||||
content: "As edge intelligence grows, local compute units require custom processor topologies. In this article, we details the exact synthesis settings and pipelining optimizations that enabled our quad-core RISC-V IP block to achieve 35% better performance per watt compared to baseline architectures. We review cache organization, instruction fetch queue sizing, and how we tackled branch prediction overheads within tightly constrained FPGA silicon boundaries.",
|
||||
author: "Dr. Marcus Vance",
|
||||
publishDate: "2026-06-18",
|
||||
thumbnailUrl: "https://images.unsplash.com/photo-1601524909162-be87252be298?w=500&auto=format&fit=crop&q=60",
|
||||
readTime: "6 min read",
|
||||
tags: ["RISC-V", "Hardware-Design", "Edge-AI"],
|
||||
status: "published"
|
||||
},
|
||||
{
|
||||
title: "Introduction to CodeNuk: Scalable Microservice Architecture",
|
||||
content: "Building distributed systems often involves navigating high configuration overhead. CodeNuk solves this by providing a unified, type-safe scaffolding that integrates telemetry, connection pools, and circuit-breakers out of the box. This deep-dive explains how CodeNuk leverages TypeScript decorators to declare service endpoints and automatically generate OpenAPI contracts and React Client hooks during the build phase, saving engineering weeks.",
|
||||
author: "Yasha Khandelwal",
|
||||
publishDate: "2026-06-25",
|
||||
thumbnailUrl: "https://images.unsplash.com/photo-1555066931-4365d14bab8c?w=500&auto=format&fit=crop&q=60",
|
||||
readTime: "8 min read",
|
||||
tags: ["CodeNuk", "TypeScript", "Microservices"],
|
||||
status: "published"
|
||||
}
|
||||
]
|
||||
});
|
||||
console.log('Seeded Blog Posts.');
|
||||
}
|
||||
|
||||
console.log('Seeding completed successfully.');
|
||||
}
|
||||
|
||||
|
||||
@ -15,7 +15,6 @@ import authRoutes from './routes/auth.routes';
|
||||
import assetRoutes from './routes/asset.routes';
|
||||
import orgRoutes from './routes/organization.routes';
|
||||
import legalRoutes from './routes/legal.routes';
|
||||
import blogRoutes from './routes/blog.routes';
|
||||
|
||||
import { ensureBucketExists } from './utils/s3';
|
||||
import { originStorage } from './utils/origin-storage';
|
||||
@ -108,7 +107,6 @@ app.use('/api/v1/auth', authRoutes);
|
||||
app.use('/api/v1/assets', assetRoutes);
|
||||
app.use('/api/v1/organizations', orgRoutes);
|
||||
app.use('/api/v1/legal', legalRoutes);
|
||||
app.use('/api/v1/blog', blogRoutes);
|
||||
|
||||
app.get('/api/v1/health', (req: Request, res: Response) => {
|
||||
res.status(200).json({ status: 'success', message: 'API is fully functional and real.' });
|
||||
|
||||
@ -60,8 +60,8 @@ export class AuthController {
|
||||
|
||||
const result = await this.authService.updatePartner(partnerId, {
|
||||
partnerGroup: (partnerGroup === null || partnerGroup === '') ? null : partnerGroup,
|
||||
assignedNdaId: assignedNdaId === null ? undefined : assignedNdaId,
|
||||
assignedMsaId: assignedMsaId === null ? undefined : assignedMsaId,
|
||||
assignedNdaId: assignedNdaId === undefined ? undefined : assignedNdaId,
|
||||
assignedMsaId: assignedMsaId === undefined ? undefined : assignedMsaId,
|
||||
sharedAssetIds,
|
||||
mfaEnabled,
|
||||
});
|
||||
@ -153,5 +153,24 @@ export class AuthController {
|
||||
res.status(200).json(user);
|
||||
} catch(err) { next(err); }
|
||||
};
|
||||
|
||||
public updateProfile = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const userId = (req as any).user?.userId;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
const { password, companyName, website, sector, companySize, defaultTheme } = req.body;
|
||||
const updatedUser = await this.authService.updateProfile(userId, {
|
||||
password,
|
||||
companyName,
|
||||
website,
|
||||
sector,
|
||||
companySize,
|
||||
defaultTheme
|
||||
});
|
||||
res.status(200).json(updatedUser);
|
||||
} catch (err) { next(err); }
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -1,91 +0,0 @@
|
||||
import { Response, NextFunction } from 'express';
|
||||
import { BlogService } from '../services/blog.service';
|
||||
import { AuthRequest } from '../middleware/auth.middleware';
|
||||
import prisma from '../utils/db';
|
||||
|
||||
export class BlogController {
|
||||
private blogService = new BlogService();
|
||||
|
||||
public listBlogPosts = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { status, tag, search, author } = req.query;
|
||||
|
||||
const filters: any = {};
|
||||
if (typeof tag === 'string') filters.tag = tag;
|
||||
if (typeof search === 'string') filters.search = search;
|
||||
if (typeof author === 'string') filters.author = author;
|
||||
|
||||
// Restrict status access if user is not admin
|
||||
if (req.user?.role !== 'ADMIN') {
|
||||
filters.status = 'published';
|
||||
} else if (typeof status === 'string') {
|
||||
filters.status = status;
|
||||
}
|
||||
|
||||
const posts = await this.blogService.getBlogPosts(filters);
|
||||
res.status(200).json(posts);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
|
||||
public getBlogPost = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const post = await this.blogService.getBlogPostById(req.params.id);
|
||||
if (!post) {
|
||||
return res.status(404).json({ error: 'Blog post not found' });
|
||||
}
|
||||
|
||||
// Clients cannot view drafts
|
||||
if (post.status !== 'published' && req.user?.role !== 'ADMIN') {
|
||||
return res.status(403).json({ error: 'Access forbidden' });
|
||||
}
|
||||
|
||||
res.status(200).json(post);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
|
||||
public createBlogPost = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
let authorVal = req.body.author || 'Technical Architect';
|
||||
if (!req.body.author && req.user?.userId) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.user.userId }
|
||||
});
|
||||
if (user) {
|
||||
authorVal = user.email;
|
||||
}
|
||||
}
|
||||
|
||||
const postData = {
|
||||
...req.body,
|
||||
author: authorVal,
|
||||
};
|
||||
|
||||
const post = await this.blogService.createBlogPost(postData);
|
||||
res.status(201).json(post);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
|
||||
public updateBlogPost = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const post = await this.blogService.updateBlogPost(req.params.id, req.body);
|
||||
res.status(200).json(post);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
|
||||
public deleteBlogPost = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
await this.blogService.deleteBlogPost(req.params.id);
|
||||
res.status(204).send();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -12,6 +12,7 @@ router.post('/refresh', authController.refresh);
|
||||
|
||||
// Invite Flow
|
||||
router.get('/me', authenticate, authController.getCurrentUser);
|
||||
router.put('/profile', authenticate, authController.updateProfile);
|
||||
router.post('/invite', authenticate, requireRole('ADMIN'), authController.invitePartner);
|
||||
router.get('/invite/:token', authController.validateInvite);
|
||||
router.post('/invite/accept', authController.acceptInvite);
|
||||
|
||||
@ -1,16 +0,0 @@
|
||||
import { Router } from 'express';
|
||||
import { BlogController } from '../controllers/blog.controller';
|
||||
import { authenticate, requireRole } from '../middleware/auth.middleware';
|
||||
|
||||
const router = Router();
|
||||
const blogController = new BlogController();
|
||||
|
||||
router.use(authenticate);
|
||||
|
||||
router.get('/', blogController.listBlogPosts);
|
||||
router.get('/:id', blogController.getBlogPost);
|
||||
router.post('/new', requireRole('ADMIN'), blogController.createBlogPost);
|
||||
router.patch('/:id', requireRole('ADMIN'), blogController.updateBlogPost);
|
||||
router.delete('/:id', requireRole('ADMIN'), blogController.deleteBlogPost);
|
||||
|
||||
export default router;
|
||||
@ -442,6 +442,11 @@ export class AuthService {
|
||||
organizationId: true,
|
||||
onboardingStatus: true,
|
||||
partnerGroup: true,
|
||||
website: true,
|
||||
sector: true,
|
||||
companySize: true,
|
||||
defaultTheme: true,
|
||||
companyName: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
assignedNdaId: true,
|
||||
@ -474,6 +479,11 @@ export class AuthService {
|
||||
organizationId: true,
|
||||
onboardingStatus: true,
|
||||
partnerGroup: true,
|
||||
website: true,
|
||||
sector: true,
|
||||
companySize: true,
|
||||
defaultTheme: true,
|
||||
companyName: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
assignedNdaId: true,
|
||||
@ -490,6 +500,102 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
if (user && user.role === 'PARTNER_USER' && user.organizationId) {
|
||||
const sharedAssets = await prisma.sharedAsset.findMany({
|
||||
where: {
|
||||
organizationId: user.organizationId,
|
||||
OR: [
|
||||
{ userId: null },
|
||||
{ userId: user.id }
|
||||
]
|
||||
},
|
||||
select: {
|
||||
assetId: true,
|
||||
asset: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
type: true,
|
||||
categoryId: true,
|
||||
subcategory: true,
|
||||
url: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return {
|
||||
...user,
|
||||
sharedAssets
|
||||
};
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
public async updateProfile(userId: string, data: {
|
||||
password?: string;
|
||||
companyName?: string;
|
||||
website?: string;
|
||||
sector?: string;
|
||||
companySize?: string;
|
||||
defaultTheme?: string;
|
||||
}) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: { organization: true }
|
||||
});
|
||||
|
||||
if (!user) throw new AppError('User not found', 404);
|
||||
|
||||
const updateData: any = {};
|
||||
|
||||
if (data.password) {
|
||||
updateData.passwordHash = await bcrypt.hash(data.password, 10);
|
||||
}
|
||||
if (data.website !== undefined) updateData.website = data.website;
|
||||
if (data.sector !== undefined) updateData.sector = data.sector;
|
||||
if (data.companySize !== undefined) updateData.companySize = data.companySize;
|
||||
if (data.defaultTheme !== undefined) updateData.defaultTheme = data.defaultTheme;
|
||||
if (data.companyName !== undefined) {
|
||||
updateData.companyName = data.companyName;
|
||||
if (user.organizationId) {
|
||||
await prisma.organization.update({
|
||||
where: { id: user.organizationId },
|
||||
data: { name: data.companyName }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: updateData,
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
role: true,
|
||||
mfaEnabled: true,
|
||||
organizationId: true,
|
||||
onboardingStatus: true,
|
||||
partnerGroup: true,
|
||||
website: true,
|
||||
sector: true,
|
||||
companySize: true,
|
||||
defaultTheme: true,
|
||||
companyName: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
assignedNdaId: true,
|
||||
assignedMsaId: true,
|
||||
organization: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
status: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return this.getUserById(userId);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,124 +0,0 @@
|
||||
import prisma from '../utils/db';
|
||||
|
||||
export interface BlogPostFilters {
|
||||
status?: string;
|
||||
tag?: string;
|
||||
search?: string;
|
||||
author?: string;
|
||||
}
|
||||
|
||||
export class BlogService {
|
||||
public async getBlogPosts(filters: BlogPostFilters = {}) {
|
||||
const { status, tag, search, author } = filters;
|
||||
const where: any = {};
|
||||
|
||||
if (status) {
|
||||
where.status = status;
|
||||
}
|
||||
|
||||
if (tag) {
|
||||
where.tags = {
|
||||
has: tag,
|
||||
};
|
||||
}
|
||||
|
||||
if (author) {
|
||||
where.author = {
|
||||
contains: author,
|
||||
mode: 'insensitive',
|
||||
};
|
||||
}
|
||||
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ title: { contains: search, mode: 'insensitive' } },
|
||||
{ content: { contains: search, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
|
||||
return await prisma.blogPost.findMany({
|
||||
where,
|
||||
orderBy: {
|
||||
publishDate: 'desc',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public async getBlogPostById(id: string) {
|
||||
return await prisma.blogPost.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
public async createBlogPost(data: any) {
|
||||
const { tags, content, ...rest } = data;
|
||||
|
||||
// Parse tags
|
||||
let parsedTags: string[] = [];
|
||||
if (Array.isArray(tags)) {
|
||||
parsedTags = tags;
|
||||
} else if (typeof tags === 'string' && tags.trim()) {
|
||||
try {
|
||||
parsedTags = JSON.parse(tags);
|
||||
} catch {
|
||||
parsedTags = tags.split(',').map((t: string) => t.trim()).filter(Boolean);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-calculate read time
|
||||
const wordsPerMinute = 200;
|
||||
const wordCount = content ? content.trim().split(/\s+/).length : 0;
|
||||
const readTimeVal = `${Math.max(1, Math.ceil(wordCount / wordsPerMinute))} min read`;
|
||||
|
||||
// Default publishDate if not provided
|
||||
const publishDateVal = rest.publishDate || new Date().toISOString().split('T')[0];
|
||||
|
||||
return await prisma.blogPost.create({
|
||||
data: {
|
||||
...rest,
|
||||
content: content || '',
|
||||
tags: parsedTags,
|
||||
readTime: readTimeVal,
|
||||
publishDate: publishDateVal,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public async updateBlogPost(id: string, data: any) {
|
||||
const { tags, content, ...rest } = data;
|
||||
const updateData: any = { ...rest };
|
||||
|
||||
if (content !== undefined) {
|
||||
updateData.content = content;
|
||||
// Auto-calculate read time
|
||||
const wordsPerMinute = 200;
|
||||
const wordCount = content ? content.trim().split(/\s+/).length : 0;
|
||||
updateData.readTime = `${Math.max(1, Math.ceil(wordCount / wordsPerMinute))} min read`;
|
||||
}
|
||||
|
||||
if (tags !== undefined) {
|
||||
let parsedTags: string[] = [];
|
||||
if (Array.isArray(tags)) {
|
||||
parsedTags = tags;
|
||||
} else if (typeof tags === 'string') {
|
||||
try {
|
||||
parsedTags = JSON.parse(tags);
|
||||
} catch {
|
||||
parsedTags = tags.split(',').map((t: string) => t.trim()).filter(Boolean);
|
||||
}
|
||||
}
|
||||
updateData.tags = parsedTags;
|
||||
}
|
||||
|
||||
return await prisma.blogPost.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
}
|
||||
|
||||
public async deleteBlogPost(id: string) {
|
||||
return await prisma.blogPost.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -6,7 +6,6 @@ import {
|
||||
ShieldCheck,
|
||||
ClipboardCheck,
|
||||
FolderGit2,
|
||||
BookCopy,
|
||||
Users,
|
||||
LogOut,
|
||||
Menu,
|
||||
@ -36,7 +35,6 @@ export const AdminLayout: React.FC = () => {
|
||||
{ name: "Approvals Queue", path: "/admin/approvals", icon: ClipboardCheck },
|
||||
{ name: "Legal Templates", path: "/admin/legal", icon: ShieldCheck },
|
||||
{ name: "Manage Catalog", path: "/admin/assets", icon: FolderGit2 },
|
||||
{ name: "Blog CMS", path: "/admin/blog", icon: BookCopy },
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@ -2,12 +2,11 @@ import React, { useState } from 'react';
|
||||
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useThemeStore } from '../../hooks/use-theme';
|
||||
import { useAuthStore } from '../../hooks/use-auth';
|
||||
import { Cpu, BookOpen, LogOut, CheckCircle, Clock, Menu, X, Sun, Moon, ChevronRight, ChevronLeft } from 'lucide-react';
|
||||
import { Cpu, LogOut, CheckCircle, Clock, Menu, X, Sun, Moon, ChevronRight, ChevronLeft, Settings } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
const navItems = [
|
||||
{ name: 'Assets', path: '/client', icon: Cpu, label: 'Asset Explorer' },
|
||||
{ name: 'Blog', path: '/client/blog', icon: BookOpen, label: 'Insights Blog' },
|
||||
];
|
||||
|
||||
export const ClientLayout: React.FC = () => {
|
||||
@ -18,6 +17,66 @@ export const ClientLayout: React.FC = () => {
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
|
||||
// Settings Modal State
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [defaultTheme, setDefaultTheme] = useState(user?.defaultTheme || 'dark');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [settingsError, setSettingsError] = useState('');
|
||||
const [settingsSuccess, setSettingsSuccess] = useState('');
|
||||
|
||||
const openSettings = () => {
|
||||
setDefaultTheme(user?.defaultTheme || 'dark');
|
||||
setPassword('');
|
||||
setConfirmPassword('');
|
||||
setSettingsError('');
|
||||
setSettingsSuccess('');
|
||||
setSettingsOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveSettings = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSettingsError('');
|
||||
setSettingsSuccess('');
|
||||
|
||||
if (password && password !== confirmPassword) {
|
||||
setSettingsError("Passwords do not match");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const { updateProfile } = await import('../../services/auth-api');
|
||||
const updatedUser = await updateProfile({
|
||||
defaultTheme: defaultTheme || undefined,
|
||||
password: password || undefined
|
||||
});
|
||||
|
||||
// Update auth store
|
||||
useAuthStore.getState().setAuth({
|
||||
user: updatedUser,
|
||||
accessToken: useAuthStore.getState().accessToken!
|
||||
});
|
||||
|
||||
if (defaultTheme) {
|
||||
useThemeStore.getState().setTheme(defaultTheme as any);
|
||||
}
|
||||
|
||||
setSettingsSuccess("Profile settings updated successfully!");
|
||||
setPassword('');
|
||||
setConfirmPassword('');
|
||||
|
||||
setTimeout(() => {
|
||||
setSettingsOpen(false);
|
||||
}, 1500);
|
||||
} catch (err: any) {
|
||||
setSettingsError(err.response?.data?.error || err.message || "Failed to update profile settings");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/login');
|
||||
@ -78,6 +137,17 @@ export const ClientLayout: React.FC = () => {
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
<button
|
||||
onClick={openSettings}
|
||||
className={`w-full flex items-center gap-3 py-3 rounded-xl transition-all duration-300 font-semibold tracking-wide text-sm group relative cursor-pointer ${
|
||||
isCollapsed ? 'justify-center px-0' : 'px-4'
|
||||
} text-ink-500 hover:text-ink-900 hover:bg-ink-100`}
|
||||
title={isCollapsed ? 'Profile Settings' : undefined}
|
||||
>
|
||||
<Settings className={`w-5 h-5 transition-transform group-hover:scale-110 ${settingsOpen ? 'text-ink-950' : 'text-ink-400 group-hover:text-ink-900'}`} />
|
||||
{!isCollapsed && <span>Profile Settings</span>}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
@ -110,6 +180,8 @@ export const ClientLayout: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Profile Settings button removed from here */}
|
||||
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className={`w-full flex items-center justify-center gap-2 rounded-xl text-sm font-bold tracking-wide text-red-650 hover:bg-red-500/10 transition-colors border border-transparent hover:border-red-200 cursor-pointer ${isCollapsed ? 'py-2 px-0' : 'px-4 py-2.5'}`}
|
||||
@ -124,7 +196,7 @@ export const ClientLayout: React.FC = () => {
|
||||
|
||||
{/* Ambient Glow */}
|
||||
<div className="fixed top-0 right-0 w-[500px] h-[500px] bg-ink-900/5 rounded-full blur-[150px] pointer-events-none -z-10" />
|
||||
|
||||
|
||||
{/* Mobile Header */}
|
||||
<header className="md:hidden sticky top-0 z-40 h-16 flex items-center justify-between px-4 bg-ink-0 border-b border-ink-200 shadow-sm shrink-0">
|
||||
<Link to="/client" className="flex items-center gap-2">
|
||||
@ -137,7 +209,7 @@ export const ClientLayout: React.FC = () => {
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
|
||||
{/* Mobile Menu Drawer */}
|
||||
<AnimatePresence>
|
||||
{mobileOpen && (
|
||||
@ -162,6 +234,13 @@ export const ClientLayout: React.FC = () => {
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
<button
|
||||
onClick={() => { setMobileOpen(false); openSettings(); }}
|
||||
className="flex items-center gap-3 w-full px-4 py-3 rounded-xl text-sm font-semibold text-ink-650 hover:bg-ink-100 cursor-pointer animate-fade-in"
|
||||
>
|
||||
<Settings className="w-5 h-5 text-ink-400" />
|
||||
Profile Settings
|
||||
</button>
|
||||
</nav>
|
||||
<div className="p-4 border-t border-ink-200 space-y-4">
|
||||
<button onClick={toggleTheme} className="flex items-center justify-between w-full p-3 rounded-xl bg-ink-50 border border-ink-200 text-sm font-bold text-ink-650">
|
||||
@ -173,11 +252,11 @@ export const ClientLayout: React.FC = () => {
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
|
||||
<div className="flex-1 w-full max-w-[1600px] px-4 py-4 md:px-8 mx-auto relative z-10 overflow-hidden flex flex-col min-h-0">
|
||||
<Outlet />
|
||||
</div>
|
||||
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-ink-200 bg-ink-0/50 backdrop-blur-md shrink-0">
|
||||
<div className="max-w-[1600px] mx-auto px-4 md:px-8 py-3 flex flex-col md:flex-row justify-between items-center gap-4 text-xs font-medium text-ink-500">
|
||||
@ -189,6 +268,130 @@ export const ClientLayout: React.FC = () => {
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
{/* Settings Modal */}
|
||||
<AnimatePresence>
|
||||
{settingsOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => !isSaving && setSettingsOpen(false)}
|
||||
className="fixed inset-0 bg-ink-900/60 backdrop-blur-md"
|
||||
/>
|
||||
|
||||
{/* Modal Body */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
className="relative w-full max-w-xl bg-ink-0 border border-ink-200 rounded-3xl shadow-2xl p-6 md:p-8 z-50 overflow-y-auto max-h-[90vh] text-ink-900 transition-colors duration-300"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-black tracking-tight text-ink-950">Profile Settings</h3>
|
||||
<p className="text-xs font-bold text-ink-500 mt-1">Refine your profile parameters and manage credentials.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSettingsOpen(false)}
|
||||
disabled={isSaving}
|
||||
className="p-1.5 rounded-lg bg-ink-50 border border-ink-200 text-ink-500 hover:text-ink-900 disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{settingsError && (
|
||||
<div className="mb-4 p-3 rounded-xl bg-red-500/10 border border-red-500/20 text-xs font-bold text-red-600 dark:text-red-400">
|
||||
{settingsError}
|
||||
</div>
|
||||
)}
|
||||
{settingsSuccess && (
|
||||
<div className="mb-4 p-3 rounded-xl bg-emerald-500/10 border border-emerald-500/20 text-xs font-bold text-emerald-600 dark:text-emerald-400">
|
||||
{settingsSuccess}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSaveSettings} className="space-y-4">
|
||||
{/* Read-only Email Field */}
|
||||
<div>
|
||||
<label className="block text-xs font-extrabold uppercase tracking-widest text-ink-500 mb-1.5">Email Address</label>
|
||||
<input
|
||||
type="email"
|
||||
value={user?.email || ''}
|
||||
disabled
|
||||
className="w-full px-4 py-2.5 rounded-xl bg-ink-100 border border-ink-200 text-ink-450 text-sm font-bold cursor-not-allowed"
|
||||
/>
|
||||
<p className="text-[10px] font-bold text-ink-400 mt-1">Email address cannot be changed.</p>
|
||||
</div>
|
||||
|
||||
{/* Company Profile Fields Removed */}
|
||||
|
||||
{/* Theme settings */}
|
||||
<div>
|
||||
<label className="block text-xs font-extrabold uppercase tracking-widest text-ink-500 mb-1.5">Default Theme</label>
|
||||
<select
|
||||
value={defaultTheme}
|
||||
onChange={(e) => setDefaultTheme(e.target.value)}
|
||||
className="w-full px-4 py-2.5 rounded-xl bg-ink-50 border border-ink-200 focus:border-ink-400 focus:bg-ink-0 text-sm font-bold transition-all outline-none cursor-pointer text-ink-900"
|
||||
>
|
||||
<option value="dark">Dark Theme</option>
|
||||
<option value="light">Light Theme</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Security Fields */}
|
||||
<div className="border-t border-ink-200 pt-4 mt-2">
|
||||
<h4 className="text-sm font-bold text-ink-950 mb-3">Change Password</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-extrabold uppercase tracking-widest text-ink-500 mb-1.5">New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-4 py-2.5 rounded-xl bg-ink-50 border border-ink-200 focus:border-ink-400 focus:bg-ink-0 text-sm font-bold transition-all outline-none"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-extrabold uppercase tracking-widest text-ink-500 mb-1.5">Confirm New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="w-full px-4 py-2.5 rounded-xl bg-ink-50 border border-ink-200 focus:border-ink-400 focus:bg-ink-0 text-sm font-bold transition-all outline-none"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-end gap-3 border-t border-ink-200 pt-5 mt-4">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isSaving}
|
||||
onClick={() => setSettingsOpen(false)}
|
||||
className="px-5 py-2.5 rounded-xl text-sm font-bold bg-ink-50 hover:bg-ink-100 border border-ink-200 text-ink-700 disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
className="px-6 py-2.5 rounded-xl text-sm font-bold bg-ink-900 hover:bg-ink-950 text-ink-0 hover:shadow-lg transition-all disabled:opacity-50 cursor-pointer flex items-center justify-center gap-2"
|
||||
>
|
||||
{isSaving ? 'Saving Changes...' : 'Save Settings'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -41,11 +41,7 @@ const LegalTemplatesPage = React.lazy(() =>
|
||||
default: m.LegalTemplatesPage,
|
||||
})),
|
||||
);
|
||||
const BlogCatalog = React.lazy(() =>
|
||||
import("../../features/blog/components/BlogCatalog").then((m) => ({
|
||||
default: m.BlogCatalog,
|
||||
})),
|
||||
);
|
||||
|
||||
const ClientAgreementsPage = React.lazy(() =>
|
||||
import("../../pages/ClientAgreementsPage").then((m) => ({
|
||||
default: m.ClientAgreementsPage,
|
||||
@ -135,14 +131,6 @@ export const router = createBrowserRouter([
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "blog",
|
||||
element: (
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<BlogCatalog />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@ -177,14 +165,6 @@ export const router = createBrowserRouter([
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "blog",
|
||||
element: (
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<BlogCatalog />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "approvals",
|
||||
element: (
|
||||
|
||||
@ -156,7 +156,9 @@ export const AssetCard: React.FC<AssetCardProps> = ({
|
||||
className={`group bg-ink-0 border rounded-xl p-4 transition-all duration-300 flex flex-col justify-between cursor-pointer min-h-[410px] ${
|
||||
isExpanded
|
||||
? 'absolute z-20 top-0 left-0 right-0 h-auto shadow-2xl border-ink-300 bg-ink-0'
|
||||
: 'relative w-full h-full border-ink-200 hover:border-ink-300 hover:shadow-md hover:-translate-y-0.5'
|
||||
: isRecommended
|
||||
? 'relative w-full h-full border-amber-500/35 bg-gradient-to-br from-amber-500/[0.02] via-ink-0 to-ink-0 hover:border-amber-500 hover:shadow-lg hover:shadow-amber-500/10 hover:-translate-y-0.5'
|
||||
: 'relative w-full h-full border-ink-200 hover:border-ink-300 hover:shadow-md hover:-translate-y-0.5'
|
||||
} ${isSelected ? 'border-ink-900 ring-1 ring-ink-900 bg-ink-50/30' : ''}`}
|
||||
>
|
||||
<div className="flex-grow flex flex-col">
|
||||
@ -175,7 +177,7 @@ export const AssetCard: React.FC<AssetCardProps> = ({
|
||||
{isRecommended && (
|
||||
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-amber-500 text-ink-950 text-[9px] font-extrabold uppercase tracking-wider shadow-sm border border-amber-400">
|
||||
<Sparkles className="w-2.5 h-2.5 fill-ink-950" />
|
||||
<span>Recommended</span>
|
||||
<span>Recommended for You</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -274,7 +274,10 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
|
||||
</div>
|
||||
}
|
||||
size="full"
|
||||
className={isMaximized ? '!max-w-[96vw] !max-h-[92vh] !h-[92vh] !mt-4' : '!max-w-[85vw] md:!max-w-[80vw] xl:!max-w-[75vw] !h-[75vh] !max-h-[75vh] !w-full'}
|
||||
className={isMaximized
|
||||
? '!fixed !inset-0 !z-[10001] !max-w-none !max-h-none !w-screen !h-screen !rounded-none !border-none !m-0'
|
||||
: '!max-w-[85vw] md:!max-w-[80vw] xl:!max-w-[75vw] !h-[75vh] !max-h-[75vh] !w-full'
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
@ -625,11 +628,33 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
) : isWord ? (
|
||||
<div className="flex-1 overflow-auto bg-slate-100 p-6 md:p-8 flex justify-center">
|
||||
<div className="flex-1 overflow-auto bg-slate-100 p-2 sm:p-6 md:p-8">
|
||||
<style>{`
|
||||
.docx-wrapper {
|
||||
background: transparent !important;
|
||||
padding: 0 !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
align-items: center !important;
|
||||
}
|
||||
.docx {
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1) !important;
|
||||
border: 1px solid #e2e8f0 !important;
|
||||
border-radius: 12px !important;
|
||||
max-width: 100% !important;
|
||||
margin: 0 auto 24px auto !important;
|
||||
background-color: white !important;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.docx {
|
||||
padding: 16px !important;
|
||||
margin-bottom: 12px !important;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
<div
|
||||
ref={wordContainerRef}
|
||||
className="w-full max-w-4xl bg-white shadow-md border border-slate-200 rounded-xl p-8 md:p-12 overflow-y-auto"
|
||||
style={{ minHeight: '842px' }}
|
||||
className="w-full max-w-4xl mx-auto"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@ -1,328 +0,0 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import type { BlogPost } from "../../../types";
|
||||
import { getBlogPosts, createBlogPost } from "../../../services/blog-api";
|
||||
import { useAuthStore } from "../../../hooks/use-auth";
|
||||
import {
|
||||
BookOpen,
|
||||
User,
|
||||
Calendar,
|
||||
Clock,
|
||||
Plus,
|
||||
Sparkles,
|
||||
Send,
|
||||
} from "lucide-react";
|
||||
import Modal from "../../../components/ui/Modal";
|
||||
import { PageLayout } from "../../../components/layout/PageLayout";
|
||||
|
||||
export const BlogCatalog: React.FC = () => {
|
||||
const { user } = useAuthStore();
|
||||
const [posts, setPosts] = useState<BlogPost[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const isAdmin = user?.role === "ADMIN";
|
||||
|
||||
// CMS modal state
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [title, setTitle] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
const [tagsInput, setTagsInput] = useState("");
|
||||
const [thumbnailUrl, setThumbnailUrl] = useState("");
|
||||
const [status, setStatus] = useState<"draft" | "published">("draft");
|
||||
const [formError, setFormError] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const fetchPosts = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getBlogPosts();
|
||||
setPosts(data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchPosts();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setFormError("");
|
||||
|
||||
if (!title.trim() || !content.trim()) {
|
||||
setFormError("Title and content are required.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const tags = tagsInput
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t.length > 0);
|
||||
const payload = {
|
||||
title,
|
||||
content,
|
||||
tags,
|
||||
thumbnailUrl: thumbnailUrl.trim() || undefined,
|
||||
status,
|
||||
author: "Technical Architect",
|
||||
};
|
||||
|
||||
const newPost = await createBlogPost(payload);
|
||||
setPosts((prev) => [newPost, ...prev]);
|
||||
|
||||
setTitle("");
|
||||
setContent("");
|
||||
setTagsInput("");
|
||||
setThumbnailUrl("");
|
||||
setStatus("draft");
|
||||
setIsOpen(false);
|
||||
} catch (err) {
|
||||
setFormError("Failed to publish article.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Header component
|
||||
const headerNode = (
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-ink-800">
|
||||
Engineering Blog & Insights
|
||||
</h2>
|
||||
<p className="text-sm text-ink-600">
|
||||
Deep-dives into RISC-V pipelining, CodeNuk scaffolding practices,
|
||||
and edge security optimizations.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Toolbar component (only render if admin is true to show Write Post action)
|
||||
const toolbarNode = isAdmin ? (
|
||||
<div className="flex justify-end p-3.5 bg-ink-0 border border-ink-200 rounded-xl shadow-sm">
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-ink-900 px-4 py-2 text-xs font-semibold text-ink-0 hover:bg-ink-800 transition-all shadow-sm cursor-pointer"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Write Post
|
||||
</button>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<PageLayout header={headerNode} toolbar={toolbarNode}>
|
||||
<div className="p-5 flex-1 min-h-0 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{[1, 2].map((n) => (
|
||||
<div
|
||||
key={n}
|
||||
className="animate-pulse rounded-xl border border-ink-200 bg-ink-0 p-5 space-y-4"
|
||||
>
|
||||
<div className="h-48 rounded-lg bg-ink-100" />
|
||||
<div className="h-4 w-3/4 rounded bg-ink-100" />
|
||||
<div className="h-20 rounded bg-ink-100" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : posts.length === 0 ? (
|
||||
<div className="rounded-xl border border-ink-200 bg-ink-0 p-12 text-center">
|
||||
<BookOpen className="h-8 w-8 text-ink-300 mx-auto mb-2" />
|
||||
<p className="text-sm text-ink-600">No blog posts published yet.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{posts.map((post) => (
|
||||
<article
|
||||
key={post.id}
|
||||
className="rounded-xl border border-ink-200 bg-ink-0 shadow-sm overflow-hidden flex flex-col hover:border-ink-400 transition-all duration-300"
|
||||
>
|
||||
<div className="h-48 w-full bg-ink-100 relative">
|
||||
<img
|
||||
src={post.thumbnailUrl}
|
||||
alt={post.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
{isAdmin && (
|
||||
<span
|
||||
className={`absolute right-3 top-3 rounded-full border px-2.5 py-0.5 text-[9px] font-bold uppercase tracking-wider ${
|
||||
post.status === "published"
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
||||
: "bg-ink-100 text-ink-700 border-ink-200"
|
||||
}`}
|
||||
>
|
||||
{post.status}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-5 flex-1 flex flex-col justify-between space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-3 text-[10px] font-bold text-ink-600 uppercase tracking-wide">
|
||||
<span className="flex items-center gap-1">
|
||||
<User className="h-3.5 w-3.5" />
|
||||
{post.author}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
{post.publishDate}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
{post.readTime}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-base font-bold text-ink-800 leading-snug line-clamp-1">
|
||||
{post.title}
|
||||
</h3>
|
||||
<p className="text-xs text-ink-600 leading-relaxed line-clamp-3">
|
||||
{post.content}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 pt-2 border-t border-ink-100">
|
||||
{post.tags.map((t) => (
|
||||
<span
|
||||
key={t}
|
||||
className="rounded bg-ink-50 px-2 py-0.5 text-[9px] font-semibold text-ink-700 border border-ink-200"
|
||||
>
|
||||
#{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Post Creator Modal */}
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-ink-900" />
|
||||
Write Blog Article
|
||||
</span>
|
||||
}
|
||||
subtitle="Draft or publish a technical write-up for the developer channel."
|
||||
size="md"
|
||||
>
|
||||
{formError && (
|
||||
<div className="mb-4 rounded-lg bg-red-500/10 border border-red-500/20 p-3 text-xs font-semibold text-red-650">
|
||||
{formError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink-700 mb-1">
|
||||
Article Title
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="e.g. Optimizing Pipeline Hazards in RV64GC Core Designs"
|
||||
className="w-full rounded-lg border border-ink-200 px-3.5 py-2 text-sm focus:border-ink-900/50 focus:outline-none bg-ink-50 text-ink-900 placeholder-ink-400"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink-700 mb-1">
|
||||
Content (Markdown supported)
|
||||
</label>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="Write the full post text..."
|
||||
rows={6}
|
||||
className="w-full rounded-lg border border-ink-200 px-3.5 py-2 text-sm focus:border-ink-900/50 focus:outline-none resize-y bg-ink-50 text-ink-900 placeholder-ink-400"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink-700 mb-1">
|
||||
Tags (comma-separated)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tagsInput}
|
||||
onChange={(e) => setTagsInput(e.target.value)}
|
||||
placeholder="RISC-V, RTL-Design, Edge-Compute"
|
||||
className="w-full rounded-lg border border-ink-200 px-3.5 py-2 text-sm focus:border-ink-900/50 focus:outline-none bg-ink-50 text-ink-900 placeholder-ink-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink-700 mb-1">
|
||||
Article Cover Photo URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={thumbnailUrl}
|
||||
onChange={(e) => setThumbnailUrl(e.target.value)}
|
||||
placeholder="https://images.unsplash.com/..."
|
||||
className="w-full rounded-lg border border-ink-200 px-3.5 py-2 text-sm focus:border-ink-900/50 focus:outline-none bg-ink-50 text-ink-900 placeholder-ink-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink-700 mb-1">
|
||||
Publish Status
|
||||
</label>
|
||||
<div className="flex gap-4 mt-2">
|
||||
<label className="flex items-center gap-1.5 text-xs text-ink-800 font-semibold cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="blogStatus"
|
||||
checked={status === "draft"}
|
||||
onChange={() => setStatus("draft")}
|
||||
className="text-ink-900 focus:ring-ink-900/20"
|
||||
/>
|
||||
Draft
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-xs text-ink-800 font-semibold cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="blogStatus"
|
||||
checked={status === "published"}
|
||||
onChange={() => setStatus("published")}
|
||||
className="text-ink-900 focus:ring-ink-900/20"
|
||||
/>
|
||||
Published
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-ink-100 pt-4 flex justify-end gap-3 mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="rounded-lg border border-ink-200 bg-ink-0 px-4 py-2 text-sm font-semibold text-ink-600 hover:bg-ink-50 cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="rounded-lg bg-ink-900 px-5 py-2 text-sm font-semibold text-ink-0 hover:bg-ink-800 flex items-center gap-1.5 cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
{submitting ? "Publishing..." : "Publish Article"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</PageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlogCatalog;
|
||||
@ -3,6 +3,7 @@ import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import type { User, AuthResponse } from '../types/auth';
|
||||
import { refreshAuthToken } from '../services/auth-api';
|
||||
import { axiosInstance } from '../services/axios';
|
||||
import { useThemeStore } from './use-theme';
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
@ -21,12 +22,17 @@ export const useAuthStore = create<AuthState>()(
|
||||
isAuthenticated: false,
|
||||
accessToken: null,
|
||||
isInitializing: true,
|
||||
setAuth: (data) => set({
|
||||
user: data.user,
|
||||
accessToken: data.accessToken,
|
||||
isAuthenticated: true,
|
||||
isInitializing: false,
|
||||
}),
|
||||
setAuth: (data) => {
|
||||
if (data.user?.defaultTheme) {
|
||||
useThemeStore.getState().setTheme(data.user.defaultTheme as any);
|
||||
}
|
||||
set({
|
||||
user: data.user,
|
||||
accessToken: data.accessToken,
|
||||
isAuthenticated: true,
|
||||
isInitializing: false,
|
||||
});
|
||||
},
|
||||
logout: () => set({
|
||||
user: null,
|
||||
accessToken: null,
|
||||
@ -39,6 +45,9 @@ export const useAuthStore = create<AuthState>()(
|
||||
if (state.accessToken && state.user) {
|
||||
try {
|
||||
const res = await axiosInstance.get('/auth/me');
|
||||
if (res.data?.defaultTheme) {
|
||||
useThemeStore.getState().setTheme(res.data.defaultTheme as any);
|
||||
}
|
||||
set({
|
||||
user: res.data,
|
||||
isAuthenticated: true,
|
||||
@ -58,6 +67,9 @@ export const useAuthStore = create<AuthState>()(
|
||||
// Try refreshing token
|
||||
try {
|
||||
const data = await refreshAuthToken();
|
||||
if (data.user?.defaultTheme) {
|
||||
useThemeStore.getState().setTheme(data.user.defaultTheme as any);
|
||||
}
|
||||
set({
|
||||
user: data.user,
|
||||
accessToken: data.accessToken,
|
||||
|
||||
@ -6,6 +6,7 @@ interface ThemeState {
|
||||
theme: Theme;
|
||||
toggleTheme: () => void;
|
||||
initTheme: () => void;
|
||||
setTheme: (theme: Theme) => void;
|
||||
}
|
||||
|
||||
export const useThemeStore = create<ThemeState>((set) => ({
|
||||
@ -31,5 +32,14 @@ export const useThemeStore = create<ThemeState>((set) => ({
|
||||
}
|
||||
|
||||
return { theme: initialTheme };
|
||||
}),
|
||||
setTheme: (theme) => set(() => {
|
||||
localStorage.setItem('theme-preference', theme);
|
||||
if (theme === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
return { theme };
|
||||
})
|
||||
}));
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { User, Asset, Category, BlogPost, Announcement } from '../types';
|
||||
import type { User, Asset, Category, Announcement } from '../types';
|
||||
import { initializeStorage } from './mock-data';
|
||||
|
||||
// Ensure data is seeded in localStorage
|
||||
@ -90,19 +90,7 @@ export const apiClient = {
|
||||
return { data: categories as unknown as T, status: 200 };
|
||||
}
|
||||
|
||||
// Blog posts
|
||||
if (cleanUrl === '/blog') {
|
||||
const posts = getStored<BlogPost[]>('t4b_blog_posts');
|
||||
// Filter out drafts for client roles
|
||||
const activeUserEmail = sessionStorage.getItem('t4b_session_email');
|
||||
const users = getStored<User[]>('t4b_users');
|
||||
const activeUser = users.find(u => u.email === activeUserEmail);
|
||||
let filtered = [...posts];
|
||||
if (!activeUser || activeUser.role !== 'ADMIN') {
|
||||
filtered = filtered.filter(p => p.status === 'published');
|
||||
}
|
||||
return { data: filtered as unknown as T, status: 200 };
|
||||
}
|
||||
|
||||
|
||||
// Announcements
|
||||
if (cleanUrl === '/announcements') {
|
||||
@ -263,23 +251,7 @@ export const apiClient = {
|
||||
return { data: newAsset as unknown as T, status: 201 };
|
||||
}
|
||||
|
||||
if (url === '/blog/new') {
|
||||
const posts = getStored<BlogPost[]>('t4b_blog_posts');
|
||||
const newPost: BlogPost = {
|
||||
id: `blog-${Date.now()}`,
|
||||
title: payload.title,
|
||||
content: payload.content,
|
||||
author: payload.author || 'Admin Staff',
|
||||
publishDate: new Date().toISOString().split('T')[0],
|
||||
thumbnailUrl: payload.thumbnailUrl || 'https://images.unsplash.com/photo-1555066931-4365d14bab8c?w=500&auto=format&fit=crop&q=60',
|
||||
readTime: `${Math.ceil(payload.content.split(' ').length / 200)} min read`,
|
||||
tags: payload.tags || [],
|
||||
status: payload.status || 'draft'
|
||||
};
|
||||
posts.push(newPost);
|
||||
setStored('t4b_blog_posts', posts);
|
||||
return { data: newPost as unknown as T, status: 201 };
|
||||
}
|
||||
|
||||
|
||||
throw { status: 404, message: 'Route not found' };
|
||||
},
|
||||
@ -287,6 +259,30 @@ export const apiClient = {
|
||||
put: async <T>(url: string, payload: any): Promise<ApiResponse<T>> => {
|
||||
await delay();
|
||||
|
||||
if (url === '/auth/profile') {
|
||||
const activeUserEmail = sessionStorage.getItem('t4b_session_email');
|
||||
const users = getStored<User[]>('t4b_users');
|
||||
const index = users.findIndex(u => u.email === activeUserEmail);
|
||||
|
||||
if (index === -1) {
|
||||
throw { status: 401, message: 'Unauthorized' };
|
||||
}
|
||||
|
||||
const user = users[index];
|
||||
if (payload.password) {
|
||||
user.passwordHash = 'hashed_new_password';
|
||||
}
|
||||
if (payload.companyName !== undefined) user.companyName = payload.companyName;
|
||||
if (payload.website !== undefined) user.website = payload.website;
|
||||
if (payload.sector !== undefined) user.sector = payload.sector;
|
||||
if (payload.companySize !== undefined) user.companySize = payload.companySize;
|
||||
if (payload.defaultTheme !== undefined) user.defaultTheme = payload.defaultTheme;
|
||||
|
||||
users[index] = user;
|
||||
setStored('t4b_users', users);
|
||||
return { data: user as unknown as T, status: 200 };
|
||||
}
|
||||
|
||||
if (url.startsWith('/admin/clients/')) {
|
||||
const clientId = url.split('/admin/clients/')[1];
|
||||
const users = getStored<User[]>('t4b_users');
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { User, Asset, Category, BlogPost, Announcement } from "../types";
|
||||
import type { User, Asset, Category, Announcement } from "../types";
|
||||
|
||||
// Pre-defined categories
|
||||
export const SEED_CATEGORIES: Category[] = [
|
||||
@ -149,35 +149,7 @@ export const SEED_ASSETS: Asset[] = [
|
||||
},
|
||||
];
|
||||
|
||||
// Pre-defined seed blog posts
|
||||
export const SEED_BLOG_POSTS: BlogPost[] = [
|
||||
{
|
||||
id: "blog-1",
|
||||
title: "Unlocking Ultra-Low Latency: Synthesis of RISC-V in Edge Devices",
|
||||
content:
|
||||
"As edge intelligence grows, local compute units require custom processor topologies. In this article, we details the exact synthesis settings and pipelining optimizations that enabled our quad-core RISC-V IP block to achieve 35% better performance per watt compared to baseline architectures. We review cache organization, instruction fetch queue sizing, and how we tackled branch prediction overheads within tightly constrained FPGA silicon boundaries.",
|
||||
author: "Dr. Marcus Vance",
|
||||
publishDate: "2026-06-18",
|
||||
thumbnailUrl:
|
||||
"https://images.unsplash.com/photo-1601524909162-be87252be298?w=500&auto=format&fit=crop&q=60&ixlib=rb-4.0.3",
|
||||
readTime: "6 min read",
|
||||
tags: ["RISC-V", "Hardware-Design", "Edge-AI"],
|
||||
status: "published",
|
||||
},
|
||||
{
|
||||
id: "blog-2",
|
||||
title: "Introduction to CodeNuk: Scalable Microservice Architecture",
|
||||
content:
|
||||
"Building distributed systems often involves navigating high configuration overhead. CodeNuk solves this by providing a unified, type-safe scaffolding that integrates telemetry, connection pools, and circuit-breakers out of the box. This deep-dive explains how CodeNuk leverages TypeScript decorators to declare service endpoints and automatically generate OpenAPI contracts and React Client hooks during the build phase, saving engineering weeks.",
|
||||
author: "Yasha Khandelwal",
|
||||
publishDate: "2026-06-25",
|
||||
thumbnailUrl:
|
||||
"https://images.unsplash.com/photo-1555066931-4365d14bab8c?w=500&auto=format&fit=crop&q=60&ixlib=rb-4.0.3",
|
||||
readTime: "8 min read",
|
||||
tags: ["CodeNuk", "TypeScript", "Microservices"],
|
||||
status: "published",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
// Pre-defined seed announcements
|
||||
export const SEED_ANNOUNCEMENTS: Announcement[] = [
|
||||
@ -283,9 +255,7 @@ export const initializeStorage = () => {
|
||||
localStorage.setItem("t4b_categories", JSON.stringify(SEED_CATEGORIES));
|
||||
}
|
||||
|
||||
if (!localStorage.getItem("t4b_blog_posts")) {
|
||||
localStorage.setItem("t4b_blog_posts", JSON.stringify(SEED_BLOG_POSTS));
|
||||
}
|
||||
|
||||
|
||||
if (!localStorage.getItem("t4b_announcements")) {
|
||||
localStorage.setItem(
|
||||
|
||||
@ -62,6 +62,25 @@ export const AssetsPage = () => {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>("ALL");
|
||||
|
||||
// Find recommended assets based on user's partnerGroup matching any AssetGroup.name (supports comma-separated multiple groups)
|
||||
const partnerGroupStrings = user?.partnerGroup && user.role === "PARTNER_USER"
|
||||
? user.partnerGroup.split(',').map(s => s.trim().toLowerCase())
|
||||
: [];
|
||||
|
||||
// Only recommend assets that the partner actually has permission to access, and deduplicate
|
||||
const recommendedAssets = (() => {
|
||||
if (partnerGroupStrings.length === 0) return [];
|
||||
|
||||
const activeGroups = groups.filter(g => partnerGroupStrings.includes(g.name.trim().toLowerCase()));
|
||||
|
||||
return activeGroups
|
||||
.flatMap(g => g.assets)
|
||||
.filter((recAsset, index, self) =>
|
||||
self.findIndex(a => a.id === recAsset.id) === index &&
|
||||
assets.some(allAsset => allAsset.id === recAsset.id)
|
||||
);
|
||||
})();
|
||||
|
||||
// Modal Control States
|
||||
const [isUploadOpen, setIsUploadOpen] = useState(false);
|
||||
const [isEditOpen, setIsEditOpen] = useState(false);
|
||||
@ -82,10 +101,7 @@ export const AssetsPage = () => {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const availableCategories = new Set(
|
||||
assets.map((asset) => asset.categoryId || "General")
|
||||
);
|
||||
if (selectedCategory !== "ALL" && !availableCategories.has(selectedCategory)) {
|
||||
if (selectedCategory !== "ALL" && selectedCategory !== "RECOMMENDED") {
|
||||
setSelectedCategory("ALL");
|
||||
}
|
||||
}, [assets, selectedCategory]);
|
||||
@ -224,23 +240,6 @@ export const AssetsPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const assignedGroups = user?.partnerGroup && user.role === "PARTNER_USER"
|
||||
? user.partnerGroup.split(',').map(s => s.trim())
|
||||
: [];
|
||||
|
||||
const CATEGORIES = [
|
||||
"ALL",
|
||||
...assignedGroups,
|
||||
...Array.from(
|
||||
new Set(
|
||||
assets
|
||||
.map((asset) => asset.categoryId || "General")
|
||||
.filter((catId): catId is string => !!catId)
|
||||
.filter(catId => !assignedGroups.some(g => g.toLowerCase() === catId.toLowerCase()))
|
||||
)
|
||||
).sort()
|
||||
];
|
||||
|
||||
const filteredAssets = assets.filter((asset) => {
|
||||
const query = searchQuery.toLowerCase().trim();
|
||||
|
||||
@ -254,13 +253,10 @@ export const AssetsPage = () => {
|
||||
asset.type.toLowerCase().includes(query) ||
|
||||
asset.tags.some((tag) => tag.toLowerCase().includes(query));
|
||||
|
||||
const groupForFilter = groups.find(g => g.name.toLowerCase() === selectedCategory.toLowerCase());
|
||||
const matchesCategory =
|
||||
selectedCategory === "ALL" ||
|
||||
(groupForFilter
|
||||
? groupForFilter.assets.some(a => a.id === asset.id)
|
||||
: (asset.categoryId === selectedCategory || (!asset.categoryId && selectedCategory === "General"))
|
||||
);
|
||||
(selectedCategory === "RECOMMENDED" &&
|
||||
recommendedAssets.some((r) => r.id === asset.id));
|
||||
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
@ -290,8 +286,9 @@ export const AssetsPage = () => {
|
||||
// Toolbar component
|
||||
const toolbarNode = (
|
||||
<div className="flex flex-col md:flex-row gap-4 items-center justify-between p-3.5 bg-ink-0 border border-ink-200 rounded-xl shadow-sm">
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 flex-1 w-full max-w-2xl">
|
||||
<div className="relative flex-1 group">
|
||||
<div className="flex flex-col md:flex-row items-stretch md:items-center gap-3 flex-1 min-w-0 w-full">
|
||||
{/* Search Bar with stable minimum width */}
|
||||
<div className="relative w-full md:w-[280px] shrink-0 group">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Search className="w-4 h-4 text-ink-400 group-focus-within:text-ink-900 transition-colors" />
|
||||
</div>
|
||||
@ -304,28 +301,32 @@ export const AssetsPage = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1.5 overflow-x-auto pb-1 sm:pb-0 scrollbar-none">
|
||||
{CATEGORIES.map((cat) => {
|
||||
const isGroup = assignedGroups.some(g => g.toLowerCase() === cat.toLowerCase());
|
||||
return (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setSelectedCategory(cat)}
|
||||
className={`px-2.5 py-1.5 rounded-lg border text-[10px] uppercase tracking-wider font-bold transition-all whitespace-nowrap shadow-sm cursor-pointer ${
|
||||
selectedCategory === cat
|
||||
? isGroup
|
||||
? "bg-amber-500 text-ink-950 border-amber-500 font-extrabold"
|
||||
: "bg-ink-900 text-ink-0 border-ink-900"
|
||||
: isGroup
|
||||
? "bg-amber-500/10 text-amber-400 border-amber-500/20 hover:bg-amber-500/20"
|
||||
: "bg-ink-50 text-ink-700 border-ink-200 hover:bg-ink-100"
|
||||
}`}
|
||||
>
|
||||
{isGroup ? `⭐ ${cat}` : cat}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* Toggle Controls: All Assets vs Recommended */}
|
||||
{user?.role === "PARTNER_USER" && recommendedAssets.length > 0 && (
|
||||
<div className="flex items-center gap-1 bg-ink-50/50 p-1 rounded-xl border border-ink-200 shadow-sm shrink-0">
|
||||
<button
|
||||
onClick={() => setSelectedCategory("ALL")}
|
||||
className={`px-3.5 py-1.5 rounded-lg text-[10px] uppercase tracking-wider font-extrabold transition-all cursor-pointer whitespace-nowrap ${
|
||||
selectedCategory === "ALL"
|
||||
? "bg-ink-900 text-ink-0 shadow-sm"
|
||||
: "text-ink-600 hover:text-ink-900 hover:bg-ink-100/50"
|
||||
}`}
|
||||
>
|
||||
All Assets
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedCategory("RECOMMENDED")}
|
||||
className={`px-3.5 py-1.5 rounded-lg text-[10px] uppercase tracking-wider font-extrabold transition-all cursor-pointer whitespace-nowrap flex items-center gap-1.5 ${
|
||||
selectedCategory === "RECOMMENDED"
|
||||
? "bg-gradient-to-r from-amber-500 to-amber-600 text-zinc-950 shadow-md shadow-amber-500/25"
|
||||
: "text-amber-700 dark:text-amber-400 hover:bg-amber-500/10"
|
||||
}`}
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5 animate-pulse" />
|
||||
<span>Recommended for You</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0 w-full md:w-auto justify-end">
|
||||
@ -383,28 +384,6 @@ export const AssetsPage = () => {
|
||||
</div>
|
||||
);
|
||||
|
||||
// Find recommended assets based on user's partnerGroup matching any AssetGroup.name (supports comma-separated multiple groups)
|
||||
const partnerGroupStrings = user?.partnerGroup && user.role === "PARTNER_USER"
|
||||
? user.partnerGroup.split(',').map(s => s.trim().toLowerCase())
|
||||
: [];
|
||||
|
||||
// Only recommend assets that the partner actually has permission to access, and deduplicate
|
||||
const recommendedAssets = partnerGroupStrings.length > 0
|
||||
? groups
|
||||
.filter(g => partnerGroupStrings.includes(g.name.trim().toLowerCase()))
|
||||
.flatMap(g => g.assets)
|
||||
.filter((recAsset, index, self) =>
|
||||
self.findIndex(a => a.id === recAsset.id) === index &&
|
||||
assets.some(allAsset => allAsset.id === recAsset.id)
|
||||
)
|
||||
: [];
|
||||
|
||||
const showRecommendations =
|
||||
user?.role === "PARTNER_USER" &&
|
||||
recommendedAssets.length > 0 &&
|
||||
searchQuery === "" &&
|
||||
selectedCategory === "ALL";
|
||||
|
||||
return (
|
||||
<PageLayout header={headerNode} toolbar={toolbarNode}>
|
||||
<div className="p-5 flex-1 min-h-0 overflow-y-auto">
|
||||
@ -414,65 +393,29 @@ export const AssetsPage = () => {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{showRecommendations && (
|
||||
<div className="mb-8 p-6 bg-gradient-to-r from-ink-950 via-slate-900 to-ink-950 border border-ink-800 rounded-2xl relative overflow-hidden shadow-xl">
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-ink-800/10 rounded-full blur-3xl -mr-16 -mt-16 pointer-events-none" />
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 relative z-10 mb-6">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||
<div className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-amber-500/10 border border-amber-500/20 text-xs font-bold text-amber-400 uppercase tracking-wider">
|
||||
{selectedCategory === "RECOMMENDED" && (
|
||||
<div className="mb-8 p-8 bg-gradient-to-br from-amber-500/[0.07] via-slate-900/40 to-slate-950/20 border border-amber-500/20 rounded-2xl relative overflow-hidden shadow-lg backdrop-blur-md">
|
||||
{/* Background decorative elements */}
|
||||
<div className="absolute top-0 right-0 w-80 h-80 bg-amber-500/[0.04] rounded-full blur-3xl -mr-16 -mt-16 pointer-events-none" />
|
||||
<div className="absolute -left-10 -bottom-10 w-60 h-60 bg-amber-600/[0.02] rounded-full blur-3xl pointer-events-none" />
|
||||
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6 relative z-10">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-amber-500/10 border border-amber-500/20 text-xs font-bold text-amber-700 dark:text-amber-400 uppercase tracking-wider">
|
||||
<Sparkles className="w-3.5 h-3.5 animate-pulse" />
|
||||
<span>Curated for You</span>
|
||||
</div>
|
||||
{(user?.partnerGroup ? user.partnerGroup.split(',').map(s => s.trim()) : []).map(g => (
|
||||
<span key={g} className="px-2.5 py-0.5 rounded-full text-xs font-extrabold uppercase tracking-wider bg-ink-800 text-amber-400 border border-ink-700 shadow-sm">
|
||||
{g}
|
||||
</span>
|
||||
))}
|
||||
<span>Curated Catalog</span>
|
||||
</span>
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-md bg-ink-900/60 text-ink-200 text-[10px] font-bold uppercase tracking-wider border border-ink-700/30">
|
||||
{recommendedAssets.length} {recommendedAssets.length === 1 ? 'Asset' : 'Assets'}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-ink-0 font-sans tracking-tight">Recommended for You</h2>
|
||||
<p className="text-slate-400 text-sm mt-1 max-w-xl">
|
||||
Hand-picked digital assets, guides, and templates selected specifically for your partner category.
|
||||
<h2 className="text-3xl font-extrabold text-ink-900 font-sans tracking-tight">Recommended for You</h2>
|
||||
<p className="text-ink-600 text-sm max-w-2xl leading-relaxed">
|
||||
These resources have been hand-picked by our team to match your partner profile and accelerate your integration.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid of Recommended Assets */}
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4"
|
||||
>
|
||||
{recommendedAssets.map((asset) => (
|
||||
<motion.div key={`rec-${asset.id}`} variants={itemVariants} className="relative h-[410px] w-full flex flex-col bg-slate-950/40 rounded-xl overflow-hidden border border-slate-800/60 shadow-lg">
|
||||
<AssetCard
|
||||
asset={asset}
|
||||
user={user}
|
||||
activeMenuId={activeMenuId}
|
||||
setActiveMenuId={setActiveMenuId}
|
||||
onViewDetails={openDetailsModal}
|
||||
onEdit={openEditModal}
|
||||
onShare={openShareModal}
|
||||
onDelete={handleDeleteAsset}
|
||||
onOpenViewer={openViewerModal}
|
||||
onDownload={handleDownload}
|
||||
onRequestDownload={handleRequestDownload}
|
||||
isSelected={selectedAssetIds.includes(asset.id)}
|
||||
onToggleSelect={handleToggleSelectAsset}
|
||||
isExpanded={expandedAssetId === asset.id}
|
||||
onToggleExpand={() => setExpandedAssetId(expandedAssetId === asset.id ? null : asset.id)}
|
||||
isRecommended={true}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showRecommendations && (
|
||||
<div className="mb-6">
|
||||
<h3 className="text-sm font-bold text-ink-500 uppercase tracking-wider">All Resources</h3>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@ -219,7 +219,8 @@ export const DirectoryPage: React.FC = () => {
|
||||
setSelectedIds: React.Dispatch<React.SetStateAction<string[]>>,
|
||||
searchQuery: string,
|
||||
setSearchQuery: React.Dispatch<React.SetStateAction<string>>,
|
||||
onToggleGroup?: (groupName: string, select: boolean) => void
|
||||
onToggleGroup?: (groupName: string, select: boolean) => void,
|
||||
activeGroupsString: string = ""
|
||||
) => {
|
||||
const filtered = allAssets.filter(asset =>
|
||||
asset.title.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
@ -258,31 +259,23 @@ export const DirectoryPage: React.FC = () => {
|
||||
{assetGroups.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 items-center bg-ink-50/50 p-2 rounded-lg border border-ink-200">
|
||||
<span className="text-[9px] font-bold uppercase tracking-wider text-ink-500 shrink-0">
|
||||
Apply Group:
|
||||
Apply Recommendation Group:
|
||||
</span>
|
||||
{assetGroups.map((g) => {
|
||||
const hasAll = g.assets.length > 0 && g.assets.every(a => selectedIds.includes(a.id));
|
||||
const activeGroupNames = activeGroupsString
|
||||
? activeGroupsString.split(',').map(s => s.trim().toLowerCase())
|
||||
: [];
|
||||
const isGroupActive = activeGroupNames.includes(g.name.trim().toLowerCase());
|
||||
return (
|
||||
<button
|
||||
key={g.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const groupAssetIds = g.assets.map(a => a.id);
|
||||
if (hasAll) {
|
||||
setSelectedIds(prev => prev.filter(id => !groupAssetIds.includes(id)));
|
||||
if (onToggleGroup) onToggleGroup(g.name, false);
|
||||
} else {
|
||||
setSelectedIds(prev => {
|
||||
const newIds = [...prev];
|
||||
groupAssetIds.forEach(id => {
|
||||
if (!newIds.includes(id)) newIds.push(id);
|
||||
});
|
||||
return newIds;
|
||||
});
|
||||
if (onToggleGroup) onToggleGroup(g.name, true);
|
||||
if (onToggleGroup) {
|
||||
onToggleGroup(g.name, !isGroupActive);
|
||||
}
|
||||
}}
|
||||
className={`px-2 py-0.5 rounded text-[9px] font-bold transition-all border cursor-pointer ${hasAll
|
||||
className={`px-2 py-0.5 rounded text-[9px] font-bold transition-all border cursor-pointer ${isGroupActive
|
||||
? "bg-ink-900 text-ink-0 border-ink-900 shadow-sm"
|
||||
: "bg-ink-0 text-ink-700 border-ink-200 hover:bg-ink-100"
|
||||
}`}
|
||||
@ -362,12 +355,12 @@ export const DirectoryPage: React.FC = () => {
|
||||
|
||||
const confirmRemoveAssetFromPartner = async () => {
|
||||
if (!selectedPartnerForAssets || !partnerAssetToRemove) return;
|
||||
|
||||
|
||||
const assetId = partnerAssetToRemove.id;
|
||||
const assetTitle = partnerAssetToRemove.title;
|
||||
const currentAssetIds = selectedPartnerForAssets.sharedAssets?.map((sa: any) => sa.assetId) || [];
|
||||
const newAssetIds = currentAssetIds.filter((id: string) => id !== assetId);
|
||||
|
||||
|
||||
try {
|
||||
const updated = await updateMutation.mutateAsync({
|
||||
partnerId: selectedPartnerForAssets.id,
|
||||
@ -389,10 +382,10 @@ export const DirectoryPage: React.FC = () => {
|
||||
|
||||
const handleAddAssetsToPartner = async () => {
|
||||
if (!selectedPartnerForAssets || selectedNewAssetIds.length === 0) return;
|
||||
|
||||
|
||||
const currentAssetIds = selectedPartnerForAssets.sharedAssets?.map((sa: any) => sa.assetId) || [];
|
||||
const newAssetIds = [...currentAssetIds, ...selectedNewAssetIds];
|
||||
|
||||
|
||||
try {
|
||||
const updated = await updateMutation.mutateAsync({
|
||||
partnerId: selectedPartnerForAssets.id,
|
||||
@ -1005,15 +998,13 @@ export const DirectoryPage: React.FC = () => {
|
||||
prev.includes(asset.id) ? prev.filter(x => x !== asset.id) : [...prev, asset.id]
|
||||
);
|
||||
}}
|
||||
className={`flex items-center gap-3 p-3 text-xs font-semibold cursor-pointer transition-all hover:bg-ink-50 ${
|
||||
isSelected ? 'bg-ink-50/70' : ''
|
||||
}`}
|
||||
className={`flex items-center gap-3 p-3 text-xs font-semibold cursor-pointer transition-all hover:bg-ink-50 ${isSelected ? 'bg-ink-50/70' : ''
|
||||
}`}
|
||||
>
|
||||
<div className={`w-4 h-4 rounded border flex items-center justify-center transition-all ${
|
||||
isSelected
|
||||
? 'border-ink-900 bg-ink-900 text-ink-0'
|
||||
: 'border-ink-200 bg-ink-50'
|
||||
}`}>
|
||||
<div className={`w-4 h-4 rounded border flex items-center justify-center transition-all ${isSelected
|
||||
? 'border-ink-900 bg-ink-900 text-ink-0'
|
||||
: 'border-ink-200 bg-ink-50'
|
||||
}`}>
|
||||
{isSelected && <CheckCircle className="w-3 h-3 stroke-[3]" />}
|
||||
</div>
|
||||
{getFileIcon(asset.type || '', asset.title)}
|
||||
@ -1057,7 +1048,7 @@ export const DirectoryPage: React.FC = () => {
|
||||
const matchedGroups = selectedPartnerForAssets?.partnerGroup
|
||||
? selectedPartnerForAssets.partnerGroup.split(',').map((s: string) => s.trim().toLowerCase())
|
||||
: [];
|
||||
|
||||
|
||||
// Map inherited assets and keep track of which group name they belong to
|
||||
const inheritedAssetsMap = new Map<string, { asset: any, groupName: string }>();
|
||||
assetGroups
|
||||
@ -1305,10 +1296,10 @@ export const DirectoryPage: React.FC = () => {
|
||||
className="rounded border-ink-300 text-ink-900 focus:ring-ink-950 w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
<label htmlFor="share-all-invite" className="text-xs font-bold text-ink-900 cursor-pointer select-none">
|
||||
Share all catalog assets by default
|
||||
Share all assets in catalog
|
||||
</label>
|
||||
</div>
|
||||
{renderAssetSelector(selectedAssetIds, handleSetSelectedAssetIds, inviteAssetSearch, setInviteAssetSearch, handleToggleInviteGroup)}
|
||||
{renderAssetSelector(selectedAssetIds, handleSetSelectedAssetIds, inviteAssetSearch, setInviteAssetSearch, handleToggleInviteGroup, partnerGroup)}
|
||||
</div>
|
||||
|
||||
{inviteResult?.error && (
|
||||
@ -1489,10 +1480,10 @@ export const DirectoryPage: React.FC = () => {
|
||||
className="rounded border-ink-300 text-ink-900 focus:ring-ink-950 w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
<label htmlFor="share-all-edit" className="text-xs font-bold text-ink-900 cursor-pointer select-none">
|
||||
Share all catalog assets by default
|
||||
Share all assets in catalog
|
||||
</label>
|
||||
</div>
|
||||
{renderAssetSelector(editAssetIds, handleSetEditAssetIds, editAssetSearch, setEditAssetSearch, handleToggleEditGroup)}
|
||||
{renderAssetSelector(editAssetIds, handleSetEditAssetIds, editAssetSearch, setEditAssetSearch, handleToggleEditGroup, editGroup)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
|
||||
@ -85,6 +85,23 @@ export const updatePartner = async (partnerId: string, params: UpdatePartnerPara
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export interface UpdateProfileParams {
|
||||
password?: string;
|
||||
companyName?: string;
|
||||
website?: string;
|
||||
sector?: string;
|
||||
companySize?: string;
|
||||
defaultTheme?: string;
|
||||
}
|
||||
|
||||
export const updateProfile = async (params: UpdateProfileParams): Promise<any> => {
|
||||
const response = await axiosInstance.put<any>(
|
||||
'/auth/profile',
|
||||
params
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const resendInvitePartner = async (partnerId: string): Promise<{ success: boolean }> => {
|
||||
const response = await axiosInstance.post<{ success: boolean }>(
|
||||
`/auth/partners/${partnerId}/resend-invite`
|
||||
|
||||
@ -1,40 +0,0 @@
|
||||
import { axiosInstance } from './axios';
|
||||
import type { BlogPost } from '../types';
|
||||
|
||||
export const getBlogPosts = async (params?: {
|
||||
status?: string;
|
||||
tag?: string;
|
||||
search?: string;
|
||||
}): Promise<BlogPost[]> => {
|
||||
const response = await axiosInstance.get<BlogPost[]>('/blog', { params });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getBlogPostById = async (id: string): Promise<BlogPost> => {
|
||||
const response = await axiosInstance.get<BlogPost>(`/blog/${id}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const createBlogPost = async (payload: {
|
||||
title: string;
|
||||
content: string;
|
||||
tags?: string[];
|
||||
thumbnailUrl?: string;
|
||||
status: 'draft' | 'published';
|
||||
author?: string;
|
||||
}): Promise<BlogPost> => {
|
||||
const response = await axiosInstance.post<BlogPost>('/blog/new', payload);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const updateBlogPost = async (
|
||||
id: string,
|
||||
payload: Partial<BlogPost>
|
||||
): Promise<BlogPost> => {
|
||||
const response = await axiosInstance.patch<BlogPost>(`/blog/${id}`, payload);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const deleteBlogPost = async (id: string): Promise<void> => {
|
||||
await axiosInstance.delete(`/blog/${id}`);
|
||||
};
|
||||
@ -5,6 +5,13 @@ export interface User {
|
||||
organizationId: string | null;
|
||||
onboardingStatus?: string;
|
||||
partnerGroup?: string | null;
|
||||
companyName?: string | null;
|
||||
website?: string | null;
|
||||
sector?: string | null;
|
||||
companySize?: string | null;
|
||||
defaultTheme?: string | null;
|
||||
organization?: { id: string; name: string } | null;
|
||||
passwordHash?: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
|
||||
@ -32,6 +32,8 @@ export interface User {
|
||||
date: string;
|
||||
};
|
||||
createdAt: string;
|
||||
passwordHash?: string;
|
||||
defaultTheme?: string | null;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
@ -57,17 +59,7 @@ export interface Asset {
|
||||
downloadsCount: number;
|
||||
}
|
||||
|
||||
export interface BlogPost {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
author: string;
|
||||
publishDate: string;
|
||||
thumbnailUrl: string;
|
||||
readTime: string;
|
||||
tags: string[];
|
||||
status: 'draft' | 'published';
|
||||
}
|
||||
|
||||
|
||||
export interface Announcement {
|
||||
id: string;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user