asset_recommendation

This commit is contained in:
kenilkb 2026-07-16 15:08:21 +05:30
parent c0c0e1a327
commit 9e4c9cc323
25 changed files with 555 additions and 924 deletions

View File

@ -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

View File

@ -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.');
}

View File

@ -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.' });

View File

@ -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); }
};
}

View File

@ -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);
}
};
}

View File

@ -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);

View File

@ -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;

View File

@ -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);
}
}

View File

@ -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 },
});
}
}

View File

@ -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 (

View File

@ -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>
);
};

View File

@ -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: (

View File

@ -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>

View File

@ -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>
) : (

View File

@ -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;

View File

@ -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,

View File

@ -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 };
})
}));

View File

@ -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');

View File

@ -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(

View File

@ -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>
)}

View File

@ -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">

View File

@ -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`

View File

@ -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}`);
};

View File

@ -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 {

View File

@ -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;