Compare commits
No commits in common. "9e4c9cc323a20a7ce66c85da3cc41a6932f80e57" and "5254e347a897b8c6600d67ae9e3cb26d3f187667" have entirely different histories.
9e4c9cc323
...
5254e347a8
@ -40,11 +40,6 @@ 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])
|
||||
@ -155,6 +150,20 @@ 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,6 +113,37 @@ 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,6 +15,7 @@ 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';
|
||||
@ -107,6 +108,7 @@ 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.' });
|
||||
|
||||
@ -59,9 +59,9 @@ export class AuthController {
|
||||
}).parse(req.body);
|
||||
|
||||
const result = await this.authService.updatePartner(partnerId, {
|
||||
partnerGroup: (partnerGroup === null || partnerGroup === '') ? null : partnerGroup,
|
||||
assignedNdaId: assignedNdaId === undefined ? undefined : assignedNdaId,
|
||||
assignedMsaId: assignedMsaId === undefined ? undefined : assignedMsaId,
|
||||
partnerGroup: partnerGroup === null ? undefined : partnerGroup,
|
||||
assignedNdaId: assignedNdaId === null ? undefined : assignedNdaId,
|
||||
assignedMsaId: assignedMsaId === null ? undefined : assignedMsaId,
|
||||
sharedAssetIds,
|
||||
mfaEnabled,
|
||||
});
|
||||
@ -153,24 +153,5 @@ 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); }
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
91
Channel-Backend/src/controllers/blog.controller.ts
Normal file
91
Channel-Backend/src/controllers/blog.controller.ts
Normal file
@ -0,0 +1,91 @@
|
||||
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,7 +12,6 @@ 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);
|
||||
|
||||
16
Channel-Backend/src/routes/blog.routes.ts
Normal file
16
Channel-Backend/src/routes/blog.routes.ts
Normal file
@ -0,0 +1,16 @@
|
||||
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;
|
||||
@ -177,43 +177,19 @@ export class AssetService {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Support comma-separated multiple groups
|
||||
const userGroups = user.partnerGroup
|
||||
? user.partnerGroup.split(',').map(s => s.trim().toLowerCase())
|
||||
: [];
|
||||
|
||||
const whereClause: any = {
|
||||
status: 'published',
|
||||
OR: [
|
||||
{
|
||||
sharedWith: {
|
||||
some: {
|
||||
organizationId: user.organizationId,
|
||||
OR: [
|
||||
{ userId: null },
|
||||
{ userId: user.id }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
if (userGroups.length > 0) {
|
||||
whereClause.OR.push({
|
||||
assetGroups: {
|
||||
some: {
|
||||
name: {
|
||||
in: userGroups,
|
||||
mode: 'insensitive'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return await prisma.asset.findMany({
|
||||
where: whereClause,
|
||||
where: {
|
||||
status: 'published',
|
||||
sharedWith: {
|
||||
some: {
|
||||
organizationId: user.organizationId,
|
||||
OR: [
|
||||
{ userId: null },
|
||||
{ userId: user.id }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
include: {
|
||||
sharedWith: {
|
||||
include: {
|
||||
|
||||
@ -137,7 +137,7 @@ export class AuthService {
|
||||
return { inviteToken, emailSent, emailError };
|
||||
}
|
||||
|
||||
public async updatePartner(partnerId: string, options: { partnerGroup?: string | null, assignedNdaId?: string | null, assignedMsaId?: string | null, sharedAssetIds?: string[], mfaEnabled?: boolean }) {
|
||||
public async updatePartner(partnerId: string, options: { partnerGroup?: string, assignedNdaId?: string, assignedMsaId?: string, sharedAssetIds?: string[], mfaEnabled?: boolean }) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: partnerId }
|
||||
});
|
||||
@ -441,12 +441,6 @@ export class AuthService {
|
||||
mfaEnabled: true,
|
||||
organizationId: true,
|
||||
onboardingStatus: true,
|
||||
partnerGroup: true,
|
||||
website: true,
|
||||
sector: true,
|
||||
companySize: true,
|
||||
defaultTheme: true,
|
||||
companyName: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
assignedNdaId: true,
|
||||
@ -478,12 +472,6 @@ export class AuthService {
|
||||
mfaEnabled: true,
|
||||
organizationId: true,
|
||||
onboardingStatus: true,
|
||||
partnerGroup: true,
|
||||
website: true,
|
||||
sector: true,
|
||||
companySize: true,
|
||||
defaultTheme: true,
|
||||
companyName: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
assignedNdaId: true,
|
||||
@ -500,102 +488,6 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
124
Channel-Backend/src/services/blog.service.ts
Normal file
124
Channel-Backend/src/services/blog.service.ts
Normal file
@ -0,0 +1,124 @@
|
||||
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,6 +6,7 @@ import {
|
||||
ShieldCheck,
|
||||
ClipboardCheck,
|
||||
FolderGit2,
|
||||
BookCopy,
|
||||
Users,
|
||||
LogOut,
|
||||
Menu,
|
||||
@ -35,6 +36,7 @@ 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,11 +2,12 @@ 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, LogOut, CheckCircle, Clock, Menu, X, Sun, Moon, ChevronRight, ChevronLeft, Settings } from 'lucide-react';
|
||||
import { Cpu, BookOpen, LogOut, CheckCircle, Clock, Menu, X, Sun, Moon, ChevronRight, ChevronLeft } 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 = () => {
|
||||
@ -17,66 +18,6 @@ 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');
|
||||
@ -137,17 +78,6 @@ 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 */}
|
||||
@ -180,8 +110,6 @@ 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'}`}
|
||||
@ -234,13 +162,6 @@ 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">
|
||||
@ -268,130 +189,6 @@ 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,7 +41,11 @@ 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,
|
||||
@ -131,6 +135,14 @@ export const router = createBrowserRouter([
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "blog",
|
||||
element: (
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<BlogCatalog />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@ -165,6 +177,14 @@ export const router = createBrowserRouter([
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "blog",
|
||||
element: (
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<BlogCatalog />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "approvals",
|
||||
element: (
|
||||
|
||||
@ -13,8 +13,7 @@ import {
|
||||
Download,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
Globe,
|
||||
Sparkles
|
||||
Globe
|
||||
} from 'lucide-react';
|
||||
import type { Asset } from '../../../types/assets';
|
||||
import type { User } from '../../../types/auth';
|
||||
@ -48,7 +47,6 @@ interface AssetCardProps {
|
||||
onToggleSelect?: (assetId: string) => void;
|
||||
isExpanded?: boolean;
|
||||
onToggleExpand?: () => void;
|
||||
isRecommended?: boolean;
|
||||
}
|
||||
|
||||
export const AssetCard: React.FC<AssetCardProps> = ({
|
||||
@ -66,8 +64,7 @@ export const AssetCard: React.FC<AssetCardProps> = ({
|
||||
isSelected = false,
|
||||
onToggleSelect,
|
||||
isExpanded = false,
|
||||
onToggleExpand,
|
||||
isRecommended = false
|
||||
onToggleExpand
|
||||
}) => {
|
||||
const isMenuOpen = activeMenuId === asset.id;
|
||||
const canDirectDownload = user?.role === 'ADMIN' || asset.isDownloadable || asset.downloadRequests?.[0]?.status === 'APPROVED';
|
||||
@ -156,9 +153,7 @@ 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'
|
||||
: 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'
|
||||
: '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">
|
||||
@ -174,12 +169,6 @@ export const AssetCard: React.FC<AssetCardProps> = ({
|
||||
className="w-3.5 h-3.5 rounded border-ink-300 bg-ink-0 text-ink-900 focus:ring-ink-900/10 cursor-pointer shadow-sm"
|
||||
/>
|
||||
)}
|
||||
{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 for You</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="absolute top-2 right-2 z-10 flex items-center gap-1">
|
||||
|
||||
@ -274,10 +274,7 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
|
||||
</div>
|
||||
}
|
||||
size="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'
|
||||
}
|
||||
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'}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
@ -628,33 +625,11 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
) : isWord ? (
|
||||
<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 className="flex-1 overflow-auto bg-slate-100 p-6 md:p-8 flex justify-center">
|
||||
<div
|
||||
ref={wordContainerRef}
|
||||
className="w-full max-w-4xl mx-auto"
|
||||
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' }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
328
Channel-Frontend/src/features/blog/components/BlogCatalog.tsx
Normal file
328
Channel-Frontend/src/features/blog/components/BlogCatalog.tsx
Normal file
@ -0,0 +1,328 @@
|
||||
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,7 +3,6 @@ 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;
|
||||
@ -22,17 +21,12 @@ export const useAuthStore = create<AuthState>()(
|
||||
isAuthenticated: false,
|
||||
accessToken: null,
|
||||
isInitializing: true,
|
||||
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,
|
||||
});
|
||||
},
|
||||
setAuth: (data) => set({
|
||||
user: data.user,
|
||||
accessToken: data.accessToken,
|
||||
isAuthenticated: true,
|
||||
isInitializing: false,
|
||||
}),
|
||||
logout: () => set({
|
||||
user: null,
|
||||
accessToken: null,
|
||||
@ -45,9 +39,6 @@ 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,
|
||||
@ -67,9 +58,6 @@ 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,7 +6,6 @@ interface ThemeState {
|
||||
theme: Theme;
|
||||
toggleTheme: () => void;
|
||||
initTheme: () => void;
|
||||
setTheme: (theme: Theme) => void;
|
||||
}
|
||||
|
||||
export const useThemeStore = create<ThemeState>((set) => ({
|
||||
@ -32,14 +31,5 @@ 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, Announcement } from '../types';
|
||||
import type { User, Asset, Category, BlogPost, Announcement } from '../types';
|
||||
import { initializeStorage } from './mock-data';
|
||||
|
||||
// Ensure data is seeded in localStorage
|
||||
@ -90,7 +90,19 @@ 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') {
|
||||
@ -251,7 +263,23 @@ 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' };
|
||||
},
|
||||
@ -259,30 +287,6 @@ 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, Announcement } from "../types";
|
||||
import type { User, Asset, Category, BlogPost, Announcement } from "../types";
|
||||
|
||||
// Pre-defined categories
|
||||
export const SEED_CATEGORIES: Category[] = [
|
||||
@ -149,7 +149,35 @@ 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[] = [
|
||||
@ -255,7 +283,9 @@ 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(
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import type { Variants } from "framer-motion";
|
||||
import { UploadCloud, Search, File, CheckCircle, Share2, Folder, Sparkles } from "lucide-react";
|
||||
import { UploadCloud, Search, File, CheckCircle, Share2, Folder } from "lucide-react";
|
||||
import { useAuthStore } from "../hooks/use-auth";
|
||||
import { axiosInstance } from "../services/axios";
|
||||
import {
|
||||
@ -62,25 +62,6 @@ 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);
|
||||
@ -101,7 +82,10 @@ export const AssetsPage = () => {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedCategory !== "ALL" && selectedCategory !== "RECOMMENDED") {
|
||||
const availableCategories = new Set(
|
||||
assets.map((asset) => asset.categoryId || "General")
|
||||
);
|
||||
if (selectedCategory !== "ALL" && !availableCategories.has(selectedCategory)) {
|
||||
setSelectedCategory("ALL");
|
||||
}
|
||||
}, [assets, selectedCategory]);
|
||||
@ -113,13 +97,12 @@ export const AssetsPage = () => {
|
||||
setAssets(assetsData);
|
||||
setSelectedAssetIds([]);
|
||||
|
||||
// Load groups for both ADMIN (for managing) and PARTNER (for recommendations)
|
||||
const groupsData = await getAssetGroups();
|
||||
setGroups(groupsData);
|
||||
|
||||
if (user?.role === "ADMIN") {
|
||||
const orgsData = await getOrganizations();
|
||||
setOrganizations(orgsData);
|
||||
|
||||
const groupsData = await getAssetGroups();
|
||||
setGroups(groupsData);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch assets data", err);
|
||||
@ -240,6 +223,17 @@ export const AssetsPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const CATEGORIES = [
|
||||
"ALL",
|
||||
...Array.from(
|
||||
new Set(
|
||||
assets
|
||||
.map((asset) => asset.categoryId || "General")
|
||||
.filter((catId): catId is string => !!catId)
|
||||
)
|
||||
).sort()
|
||||
];
|
||||
|
||||
const filteredAssets = assets.filter((asset) => {
|
||||
const query = searchQuery.toLowerCase().trim();
|
||||
|
||||
@ -255,8 +249,8 @@ export const AssetsPage = () => {
|
||||
|
||||
const matchesCategory =
|
||||
selectedCategory === "ALL" ||
|
||||
(selectedCategory === "RECOMMENDED" &&
|
||||
recommendedAssets.some((r) => r.id === asset.id));
|
||||
asset.categoryId === selectedCategory ||
|
||||
(!asset.categoryId && selectedCategory === "General");
|
||||
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
@ -286,9 +280,8 @@ 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 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="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="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>
|
||||
@ -301,32 +294,21 @@ export const AssetsPage = () => {
|
||||
/>
|
||||
</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">
|
||||
<div className="flex gap-1.5 overflow-x-auto pb-1 sm:pb-0 scrollbar-none">
|
||||
{CATEGORIES.map((cat) => (
|
||||
<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"
|
||||
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
|
||||
? "bg-ink-900 text-ink-0 border-ink-900"
|
||||
: "bg-ink-50 text-ink-700 border-ink-200 hover:bg-ink-100"
|
||||
}`}
|
||||
>
|
||||
All Assets
|
||||
{cat}
|
||||
</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>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0 w-full md:w-auto justify-end">
|
||||
@ -391,74 +373,43 @@ export const AssetsPage = () => {
|
||||
<div className="py-20 flex justify-center items-center">
|
||||
<div className="w-8 h-8 border-4 border-ink-900/30 border-t-ink-900 rounded-full animate-spin" />
|
||||
</div>
|
||||
) : filteredAssets.length === 0 ? (
|
||||
<div className="py-12 text-center bg-ink-0 border border-ink-200 rounded-xl">
|
||||
<File className="w-12 h-12 text-ink-300 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-bold text-ink-900">No assets found</h3>
|
||||
<p className="text-ink-500 text-sm mt-1">
|
||||
There are no assets matching your criteria.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{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 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-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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredAssets.length === 0 ? (
|
||||
<div className="py-12 text-center bg-ink-0 border border-ink-200 rounded-xl">
|
||||
<File className="w-12 h-12 text-ink-300 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-bold text-ink-900">No assets found</h3>
|
||||
<p className="text-ink-500 text-sm mt-1">
|
||||
There are no assets matching your criteria.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4 items-start"
|
||||
>
|
||||
{filteredAssets.map((asset) => (
|
||||
<motion.div key={asset.id} variants={itemVariants} className="relative h-[410px] w-full flex flex-col">
|
||||
<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={recommendedAssets.some(r => r.id === asset.id)}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4 items-start"
|
||||
>
|
||||
{filteredAssets.map((asset) => (
|
||||
<motion.div key={asset.id} variants={itemVariants} className="relative h-[410px] w-full flex flex-col">
|
||||
<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)}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@ -15,7 +15,6 @@ import {
|
||||
Copy,
|
||||
Trash2,
|
||||
Plus,
|
||||
Lock,
|
||||
} from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Link } from "react-router-dom";
|
||||
@ -136,16 +135,6 @@ export const DirectoryPage: React.FC = () => {
|
||||
const [mfaRequired, setMfaRequired] = useState(true);
|
||||
const [selectedAssetIds, setSelectedAssetIds] = useState<string[]>([]);
|
||||
const [inviteAssetSearch, setInviteAssetSearch] = useState("");
|
||||
const [shareAllAssets, setShareAllAssets] = useState(true);
|
||||
|
||||
// Helper for sync checkbox
|
||||
const handleSetSelectedAssetIds = (value: React.SetStateAction<string[]>) => {
|
||||
setSelectedAssetIds(prev => {
|
||||
const next = typeof value === 'function' ? (value as Function)(prev) : value;
|
||||
setShareAllAssets(next.length === allAssets.length && allAssets.length > 0);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Editing partner states
|
||||
const [editingPartner, setEditingPartner] = useState<any>(null);
|
||||
@ -156,16 +145,6 @@ export const DirectoryPage: React.FC = () => {
|
||||
const [editAssetIds, setEditAssetIds] = useState<string[]>([]);
|
||||
const [editAssetSearch, setEditAssetSearch] = useState("");
|
||||
const [isEditOpen, setIsEditOpen] = useState(false);
|
||||
const [editShareAll, setEditShareAll] = useState(false);
|
||||
|
||||
// Helper for edit sync checkbox
|
||||
const handleSetEditAssetIds = (value: React.SetStateAction<string[]>) => {
|
||||
setEditAssetIds(prev => {
|
||||
const next = typeof value === 'function' ? (value as Function)(prev) : value;
|
||||
setEditShareAll(next.length === allAssets.length && allAssets.length > 0);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const [assetGroups, setAssetGroups] = useState<AssetGroup[]>([]);
|
||||
const [selectedPartnerForAssets, setSelectedPartnerForAssets] = useState<any | null>(null);
|
||||
const [isPartnerAssetsOpen, setIsPartnerAssetsOpen] = useState(false);
|
||||
@ -175,52 +154,16 @@ export const DirectoryPage: React.FC = () => {
|
||||
const [partnerAssetToRemove, setPartnerAssetToRemove] = useState<{ id: string, title: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getAssets().then((assets) => {
|
||||
setAllAssets(assets);
|
||||
setSelectedAssetIds(assets.map(a => a.id));
|
||||
}).catch(console.error);
|
||||
getAssets().then(setAllAssets).catch(console.error);
|
||||
getLegalDocuments().then(setAllDocs).catch(console.error);
|
||||
getAssetGroups().then(setAssetGroups).catch(console.error);
|
||||
}, []);
|
||||
|
||||
const handleToggleInviteGroup = (groupName: string, select: boolean) => {
|
||||
setPartnerGroup(prev => {
|
||||
const groups = prev ? prev.split(',').map(s => s.trim()) : [];
|
||||
if (select) {
|
||||
if (!groups.some(g => g.toLowerCase() === groupName.toLowerCase())) {
|
||||
groups.push(groupName);
|
||||
}
|
||||
} else {
|
||||
const filtered = groups.filter(g => g.toLowerCase() !== groupName.toLowerCase());
|
||||
return filtered.join(', ');
|
||||
}
|
||||
return groups.join(', ');
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleEditGroup = (groupName: string, select: boolean) => {
|
||||
setEditGroup(prev => {
|
||||
const groups = prev ? prev.split(',').map(s => s.trim()) : [];
|
||||
if (select) {
|
||||
if (!groups.some(g => g.toLowerCase() === groupName.toLowerCase())) {
|
||||
groups.push(groupName);
|
||||
}
|
||||
} else {
|
||||
const filtered = groups.filter(g => g.toLowerCase() !== groupName.toLowerCase());
|
||||
return filtered.join(', ');
|
||||
}
|
||||
return groups.join(', ');
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
const renderAssetSelector = (
|
||||
selectedIds: string[],
|
||||
setSelectedIds: React.Dispatch<React.SetStateAction<string[]>>,
|
||||
searchQuery: string,
|
||||
setSearchQuery: React.Dispatch<React.SetStateAction<string>>,
|
||||
onToggleGroup?: (groupName: string, select: boolean) => void,
|
||||
activeGroupsString: string = ""
|
||||
setSearchQuery: React.Dispatch<React.SetStateAction<string>>
|
||||
) => {
|
||||
const filtered = allAssets.filter(asset =>
|
||||
asset.title.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
@ -234,6 +177,8 @@ export const DirectoryPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
@ -259,23 +204,29 @@ 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 Recommendation Group:
|
||||
Apply Group:
|
||||
</span>
|
||||
{assetGroups.map((g) => {
|
||||
const activeGroupNames = activeGroupsString
|
||||
? activeGroupsString.split(',').map(s => s.trim().toLowerCase())
|
||||
: [];
|
||||
const isGroupActive = activeGroupNames.includes(g.name.trim().toLowerCase());
|
||||
const hasAll = g.assets.length > 0 && g.assets.every(a => selectedIds.includes(a.id));
|
||||
return (
|
||||
<button
|
||||
key={g.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (onToggleGroup) {
|
||||
onToggleGroup(g.name, !isGroupActive);
|
||||
const groupAssetIds = g.assets.map(a => a.id);
|
||||
if (hasAll) {
|
||||
setSelectedIds(prev => prev.filter(id => !groupAssetIds.includes(id)));
|
||||
} else {
|
||||
setSelectedIds(prev => {
|
||||
const newIds = [...prev];
|
||||
groupAssetIds.forEach(id => {
|
||||
if (!newIds.includes(id)) newIds.push(id);
|
||||
});
|
||||
return newIds;
|
||||
});
|
||||
}
|
||||
}}
|
||||
className={`px-2 py-0.5 rounded text-[9px] font-bold transition-all border cursor-pointer ${isGroupActive
|
||||
className={`px-2 py-0.5 rounded text-[9px] font-bold transition-all border cursor-pointer ${hasAll
|
||||
? "bg-ink-900 text-ink-0 border-ink-900 shadow-sm"
|
||||
: "bg-ink-0 text-ink-700 border-ink-200 hover:bg-ink-100"
|
||||
}`}
|
||||
@ -609,13 +560,6 @@ export const DirectoryPage: React.FC = () => {
|
||||
onClick={() => {
|
||||
setInviteResult(null);
|
||||
setEmail("");
|
||||
setPartnerGroup("");
|
||||
setAssignedNdaId("");
|
||||
setAssignedMsaId("");
|
||||
setMfaRequired(true);
|
||||
setSelectedAssetIds(allAssets.map(a => a.id));
|
||||
setShareAllAssets(true);
|
||||
setInviteAssetSearch("");
|
||||
setIsInviteOpen(true);
|
||||
}}
|
||||
variant="primary"
|
||||
@ -796,7 +740,6 @@ export const DirectoryPage: React.FC = () => {
|
||||
) : (
|
||||
paginatedPartners.map((partner) => {
|
||||
const sc = getStatusConfig(partner.onboardingStatus);
|
||||
const pGroup = partner.partnerGroup;
|
||||
return (
|
||||
<tr
|
||||
key={partner.id}
|
||||
@ -829,25 +772,9 @@ export const DirectoryPage: React.FC = () => {
|
||||
setSelectedPartnerForAssets(partner);
|
||||
setIsPartnerAssetsOpen(true);
|
||||
}}
|
||||
className="text-xs font-bold text-ink-700 hover:text-ink-950 hover:underline cursor-pointer focus:outline-none flex flex-col items-start gap-0.5"
|
||||
className="text-xs font-bold text-ink-700 hover:text-ink-950 hover:underline cursor-pointer focus:outline-none"
|
||||
>
|
||||
<span>
|
||||
{(() => {
|
||||
const directCount = partner.sharedAssets?.length || 0;
|
||||
if (!pGroup) return directCount;
|
||||
const pGroupNames = pGroup.split(',').map(s => s.trim().toLowerCase());
|
||||
const uniqueGroupAssetIds = new Set<string>();
|
||||
assetGroups
|
||||
.filter(g => pGroupNames.includes(g.name.trim().toLowerCase()))
|
||||
.forEach(g => g.assets.forEach(a => uniqueGroupAssetIds.add(a.id)));
|
||||
return directCount + uniqueGroupAssetIds.size;
|
||||
})()} assets
|
||||
</span>
|
||||
{pGroup && assetGroups.some(g => pGroup.split(',').map(s => s.trim().toLowerCase()).includes(g.name.trim().toLowerCase())) && (
|
||||
<span className="text-[9px] text-amber-600 font-extrabold uppercase tracking-wide">
|
||||
Includes {pGroup}
|
||||
</span>
|
||||
)}
|
||||
{partner.sharedAssets?.length || 0} assets
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
@ -908,9 +835,7 @@ export const DirectoryPage: React.FC = () => {
|
||||
setEditNdaId(partner.assignedNdaId === null ? "NONE" : partner.assignedNdaId || "");
|
||||
setEditMsaId(partner.assignedMsaId === null ? "NONE" : partner.assignedMsaId || "");
|
||||
setEditMfaEnabled(partner.mfaEnabled);
|
||||
const sharedIds = partner.sharedAssets?.map((sa: any) => sa.assetId) || [];
|
||||
setEditAssetIds(sharedIds);
|
||||
setEditShareAll(sharedIds.length === allAssets.length && allAssets.length > 0);
|
||||
setEditAssetIds(partner.sharedAssets?.map((sa: any) => sa.assetId) || []);
|
||||
setIsEditOpen(true);
|
||||
}}
|
||||
className="p-1.5 text-ink-600 hover:text-ink-950 hover:bg-ink-100 rounded-lg transition-colors cursor-pointer"
|
||||
@ -998,13 +923,15 @@ 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)}
|
||||
@ -1044,42 +971,19 @@ export const DirectoryPage: React.FC = () => {
|
||||
</div>
|
||||
) : (
|
||||
/* Current Shared Assets List */
|
||||
(() => {
|
||||
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
|
||||
.filter(g => matchedGroups.includes(g.name.trim().toLowerCase()))
|
||||
.forEach(g => {
|
||||
g.assets.forEach(asset => {
|
||||
if (!inheritedAssetsMap.has(asset.id)) {
|
||||
inheritedAssetsMap.set(asset.id, { asset, groupName: g.name });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const inheritedAssets = Array.from(inheritedAssetsMap.values());
|
||||
const directAssets = selectedPartnerForAssets?.sharedAssets || [];
|
||||
const totalCount = directAssets.length + inheritedAssets.length;
|
||||
|
||||
if (totalCount === 0) {
|
||||
return (
|
||||
<div className="text-center py-10 bg-ink-50/50 border border-dashed border-ink-200 rounded-xl">
|
||||
<Folder className="w-10 h-10 text-ink-300 mx-auto mb-2" />
|
||||
<p className="text-xs font-bold text-ink-900 font-sans">No assets shared</p>
|
||||
<p className="text-[10px] text-ink-450 mt-1 font-sans">There are no assets currently assigned to this partner.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border border-ink-200 rounded-xl overflow-hidden divide-y divide-ink-200 bg-ink-0 max-h-[350px] overflow-y-auto">
|
||||
{/* Group Inherited Assets */}
|
||||
{inheritedAssets.map(({ asset, groupName }) => (
|
||||
<div key={`group-asset-${asset.id}`} className="flex items-center justify-between p-3 hover:bg-ink-100 transition-colors font-sans">
|
||||
(!selectedPartnerForAssets?.sharedAssets || selectedPartnerForAssets.sharedAssets.length === 0) ? (
|
||||
<div className="text-center py-10 bg-ink-50/50 border border-dashed border-ink-200 rounded-xl">
|
||||
<Folder className="w-10 h-10 text-ink-300 mx-auto mb-2" />
|
||||
<p className="text-xs font-bold text-ink-900 font-sans">No assets shared</p>
|
||||
<p className="text-[10px] text-ink-450 mt-1 font-sans">There are no assets currently assigned to this partner.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border border-ink-200 rounded-xl overflow-hidden divide-y divide-ink-200 bg-ink-0 max-h-[350px] overflow-y-auto">
|
||||
{selectedPartnerForAssets.sharedAssets.map((item: any) => {
|
||||
const asset = item.asset;
|
||||
if (!asset) return null;
|
||||
return (
|
||||
<div key={item.assetId} className="flex items-center justify-between p-3 hover:bg-ink-100 transition-colors font-sans">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{getFileIcon(asset.type || '', asset.title)}
|
||||
<div className="min-w-0 font-sans">
|
||||
@ -1088,53 +992,26 @@ export const DirectoryPage: React.FC = () => {
|
||||
<span className="text-[8px] px-1.5 py-0.2 rounded bg-ink-100 border border-ink-200 text-ink-600 font-bold uppercase tracking-wider">
|
||||
{asset.categoryId || 'General'}
|
||||
</span>
|
||||
<span className="text-[8px] px-1.5 py-0.2 rounded bg-amber-500/10 border border-amber-500/20 text-amber-700 font-extrabold uppercase tracking-wider">
|
||||
Inherited ({groupName})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-1.5 text-ink-400 cursor-not-allowed shrink-0 ml-4" title={`Inherited via ${groupName} permissions (Read-only)`}>
|
||||
<Lock className="w-4 h-4 text-ink-400" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Direct Shared Assets */}
|
||||
{directAssets.map((item: any) => {
|
||||
const asset = item.asset;
|
||||
if (!asset) return null;
|
||||
return (
|
||||
<div key={item.assetId} className="flex items-center justify-between p-3 hover:bg-ink-100 transition-colors font-sans">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{getFileIcon(asset.type || '', asset.title)}
|
||||
<div className="min-w-0 font-sans">
|
||||
<p className="text-xs font-bold text-ink-900 truncate">{asset.title}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-[8px] px-1.5 py-0.2 rounded bg-ink-100 border border-ink-200 text-ink-600 font-bold uppercase tracking-wider">
|
||||
{asset.categoryId || 'General'}
|
||||
{asset.subcategory && (
|
||||
<span className="text-[8px] px-1.5 py-0.2 rounded bg-ink-50 border border-ink-150 text-ink-500 font-semibold">
|
||||
{asset.subcategory}
|
||||
</span>
|
||||
{asset.subcategory && (
|
||||
<span className="text-[8px] px-1.5 py-0.2 rounded bg-ink-50 border border-ink-150 text-ink-500 font-semibold">
|
||||
{asset.subcategory}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemoveAssetFromPartner(item.assetId, asset.title)}
|
||||
className="p-1.5 text-red-500 hover:text-red-750 hover:bg-red-500/10 rounded-lg transition-colors cursor-pointer shrink-0 ml-4"
|
||||
title="Remove Share"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
<button
|
||||
onClick={() => handleRemoveAssetFromPartner(item.assetId, asset.title)}
|
||||
className="p-1.5 text-red-500 hover:text-red-750 hover:bg-red-500/10 rounded-lg transition-colors cursor-pointer shrink-0 ml-4"
|
||||
title="Remove Share"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
@ -1279,27 +1156,7 @@ export const DirectoryPage: React.FC = () => {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-2.5 flex items-center gap-2 p-2.5 bg-ink-50 border border-ink-200 rounded-lg">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="share-all-invite"
|
||||
checked={shareAllAssets}
|
||||
onChange={(e) => {
|
||||
const checked = e.target.checked;
|
||||
setShareAllAssets(checked);
|
||||
if (checked) {
|
||||
setSelectedAssetIds(allAssets.map(a => a.id));
|
||||
} else {
|
||||
setSelectedAssetIds([]);
|
||||
}
|
||||
}}
|
||||
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 assets in catalog
|
||||
</label>
|
||||
</div>
|
||||
{renderAssetSelector(selectedAssetIds, handleSetSelectedAssetIds, inviteAssetSearch, setInviteAssetSearch, handleToggleInviteGroup, partnerGroup)}
|
||||
{renderAssetSelector(selectedAssetIds, setSelectedAssetIds, inviteAssetSearch, setInviteAssetSearch)}
|
||||
</div>
|
||||
|
||||
{inviteResult?.error && (
|
||||
@ -1463,27 +1320,7 @@ export const DirectoryPage: React.FC = () => {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-2.5 flex items-center gap-2 p-2.5 bg-ink-50 border border-ink-200 rounded-lg">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="share-all-edit"
|
||||
checked={editShareAll}
|
||||
onChange={(e) => {
|
||||
const checked = e.target.checked;
|
||||
setEditShareAll(checked);
|
||||
if (checked) {
|
||||
setEditAssetIds(allAssets.map(a => a.id));
|
||||
} else {
|
||||
setEditAssetIds([]);
|
||||
}
|
||||
}}
|
||||
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 assets in catalog
|
||||
</label>
|
||||
</div>
|
||||
{renderAssetSelector(editAssetIds, handleSetEditAssetIds, editAssetSearch, setEditAssetSearch, handleToggleEditGroup, editGroup)}
|
||||
{renderAssetSelector(editAssetIds, setEditAssetIds, editAssetSearch, setEditAssetSearch)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
|
||||
@ -85,23 +85,6 @@ 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`
|
||||
|
||||
40
Channel-Frontend/src/services/blog-api.ts
Normal file
40
Channel-Frontend/src/services/blog-api.ts
Normal file
@ -0,0 +1,40 @@
|
||||
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}`);
|
||||
};
|
||||
@ -4,14 +4,6 @@ export interface User {
|
||||
role: 'ADMIN' | 'PARTNER_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,8 +32,6 @@ export interface User {
|
||||
date: string;
|
||||
};
|
||||
createdAt: string;
|
||||
passwordHash?: string;
|
||||
defaultTheme?: string | null;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
@ -59,7 +57,17 @@ 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