Asset Management & seach, filter funationalities

This commit is contained in:
kenilkb 2026-07-21 14:11:41 +05:30
parent b2853a952b
commit 61f537057b
24 changed files with 2025 additions and 216 deletions

View File

@ -76,8 +76,10 @@ model Asset {
thumbnailUrl String?
problemStatement String? @db.Text
solution String? @db.Text
contentType String?
folderId String?
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
verticals Vertical[] @relation("AssetVerticals")
sharedWith SharedAsset[]
downloadRequests DownloadRequest[]
assetGroups AssetGroup[]
@ -85,6 +87,30 @@ model Asset {
updatedAt DateTime @updatedAt
}
model Vertical {
id String @id @default(uuid())
name String @unique
slug String @unique
icon String?
description String?
color String?
orderIndex Int @default(0)
isActive Boolean @default(true)
assets Asset[] @relation("AssetVerticals")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model AssetNotification {
id String @id @default(uuid())
title String
message String @db.Text
sentBy String
targetOrgIds String[]
assetIds String[]
createdAt DateTime @default(now())
}
model SharedAsset {
id String @id @default(uuid())
assetId String

View File

@ -0,0 +1,148 @@
import dotenv from 'dotenv';
dotenv.config();
import prisma from '../src/utils/db';
const INITIAL_VERTICALS = [
{ name: 'Cybersecurity', slug: 'cybersecurity', icon: 'Shield', color: '#ef4444', description: 'OT, Infrastructure & Data Defense' },
{ name: 'AI & ML', slug: 'ai-ml', icon: 'Cpu', color: '#8b5cf6', description: 'Artificial Intelligence & Machine Learning' },
{ name: 'Healthcare & Pharma', slug: 'healthcare-pharma', icon: 'Activity', color: '#ec4899', description: 'Medical, Diagnostics & BioTech' },
{ name: 'Finance & Banking', slug: 'finance-banking', icon: 'Landmark', color: '#10b981', description: 'FinTech, Payments & Risk Analytics' },
{ name: 'Insurance', slug: 'insurance', icon: 'FileCheck', color: '#3b82f6', description: 'InsurTech, Claims & Underwriting' },
{ name: 'Energy & Utilities', slug: 'energy-utilities', icon: 'Zap', color: '#f59e0b', description: 'Smart Grid, Power & Renewables' },
{ name: 'Agriculture', slug: 'agriculture', icon: 'Leaf', color: '#84cc16', description: 'AgriTech, Precision Farming & Supply' },
{ name: 'Education', slug: 'education', icon: 'GraduationCap', color: '#06b6d4', description: 'EdTech, LMS & Institutional Tools' },
{ name: 'Manufacturing & IoT', slug: 'manufacturing-iot', icon: 'Factory', color: '#6366f1', description: 'Industrial Automation & Edge IoT' },
{ name: 'Automotive', slug: 'automotive', icon: 'Car', color: '#14b8a6', description: 'Connected Vehicles & Telematics' },
{ name: 'Retail & Supply Chain', slug: 'retail-supply-chain', icon: 'ShoppingBag', color: '#d97706', description: 'E-Commerce, Logistics & Smart Retail' },
{ name: 'Blockchain', slug: 'blockchain', icon: 'Link', color: '#0284c7', description: 'Smart Contracts & Web3 Infrastructure' },
];
function inferContentType(type: string, subcategory?: string | null): string {
const sub = (subcategory || '').toLowerCase().trim();
if (sub === 'case study') return 'case_study';
if (sub === 'showcase') return 'showcase';
if (sub === 'news letter' || sub === 'marketing milestone') return 'newsletter';
if (sub === 'portfolio' || sub === 'company deck' || sub === 'product showcase' || sub === 'rnd innovation') return 'portfolio';
if (sub === 'mvp') return 'mvp';
if (sub === 'workflow automation' || sub === 'worlflow automation') return 'workflow';
if (sub === 'use case') return 'use_case';
if (sub === 'test drive resources') return 'test_drive';
if (type === 'case_study') return 'case_study';
if (type === 'url') return 'showcase';
if (type.includes('pdf') || type.includes('document') || type.includes('word')) return 'document';
if (type.includes('sheet') || type.includes('csv') || type.includes('excel')) return 'spreadsheet';
if (type.includes('presentation') || type.includes('powerpoint')) return 'presentation';
if (type.includes('image')) return 'image';
return 'document';
}
function matchVerticalSlugs(title: string, description?: string | null): string[] {
const text = `${title} ${description || ''}`.toLowerCase();
const matched = new Set<string>();
if (/cybersecurity|security|threat|ot |defence|defense|ransomware|audit|hacker|firewall|fpga|compliance|kyc|aml/.test(text)) {
matched.add('cybersecurity');
}
if (/ai|machine learning|resnet|densenet|nlp|chatbot|genai|deep learning|prediction|predictive|forecasting|gpt|n8n|rag|speech/.test(text)) {
matched.add('ai-ml');
}
if (/health|medical|pharma|drug|cancer|hospital|patient|doctor|eye|blood|hematology|x-ray|brain tumor|bio|biotech|wearable|ventilator|ct scan/.test(text)) {
matched.add('healthcare-pharma');
}
if (/bank|fintech|payment|fraud|credit|loan|financial|accounting|cash|revenue|investor|audit|trade|b2b/.test(text)) {
matched.add('finance-banking');
}
if (/insurance|claims|underwriting|insurtech|policy|catastrophe|actuary/.test(text)) {
matched.add('insurance');
}
if (/energy|grid|power|renewable|ev |electric vehicle|utility|utilities|battery|charging|power plant|oms|ems|metering|solar|wind/.test(text)) {
matched.add('energy-utilities');
}
if (/agri|farm|crop|livestock|soil|aqua|pest|aquaponics|harvest|yield/.test(text)) {
matched.add('agriculture');
}
if (/education|student|learning|lms|classroom|exam|academic|textbook|proctoring|university|school/.test(text)) {
matched.add('education');
}
if (/manufactur|industrial|iot|edge|predictive maintenance|digital twin|esp32|ble board|pcb|robotics|factory|plc/.test(text)) {
matched.add('manufacturing-iot');
}
if (/vehicle|automotive|fleet|telematics|adas|car|driving|mobility|maas/.test(text)) {
matched.add('automotive');
}
if (/retail|e-commerce|supply chain|inventory|mall|store|basket|procurement|logistics|fmcg|pos/.test(text)) {
matched.add('retail-supply-chain');
}
if (/blockchain|smart contract|credentialing|traceability/.test(text)) {
matched.add('blockchain');
}
return Array.from(matched);
}
async function main() {
console.log('🚀 Starting Data Normalization & Taxonomy Seeding...');
// 1. Seed Verticals
const verticalMap = new Map<string, string>(); // slug -> id
for (const v of INITIAL_VERTICALS) {
const upserted = await prisma.vertical.upsert({
where: { slug: v.slug },
update: { name: v.name, icon: v.icon, color: v.color, description: v.description },
create: v,
});
verticalMap.set(v.slug, upserted.id);
}
console.log(`${verticalMap.size} Verticals ready.`);
// 2. Fix typos in Subcategories
await prisma.asset.updateMany({
where: { subcategory: { in: ['Showcaase', 'showcase'] } },
data: { subcategory: 'Showcase' },
});
await prisma.asset.updateMany({
where: { subcategory: 'Worlflow Automation' },
data: { subcategory: 'Workflow Automation' },
});
await prisma.asset.updateMany({
where: { subcategory: 'Use Case', categoryId: 'Marketing' },
data: { categoryId: 'Technical' },
});
await prisma.asset.updateMany({
where: { subcategory: 'MVP', categoryId: 'Presentations' },
data: { categoryId: 'Resources' },
});
console.log('✅ Subcategory typos & category alignments fixed.');
// 3. Process all assets
const assets = await prisma.asset.findMany();
let updatedCount = 0;
for (const asset of assets) {
const contentType = inferContentType(asset.type, asset.subcategory);
const matchedSlugs = matchVerticalSlugs(asset.title, asset.description);
const verticalIds = matchedSlugs.map(slug => verticalMap.get(slug)).filter(Boolean) as string[];
await prisma.asset.update({
where: { id: asset.id },
data: {
contentType,
verticals: {
set: verticalIds.map(id => ({ id })),
},
},
});
updatedCount++;
}
console.log(`🎉 Successfully normalized ${updatedCount} assets with content types & vertical tags!`);
}
main()
.catch(err => {
console.error('❌ Migration failed:', err);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@ -16,6 +16,8 @@ import assetRoutes from './routes/asset.routes';
import orgRoutes from './routes/organization.routes';
import legalRoutes from './routes/legal.routes';
import ecosystemRoutes from './routes/ecosystem.routes';
import taxonomyRoutes from './routes/taxonomy.routes';
import notificationRoutes from './routes/notification.routes';
import { ensureBucketExists } from './utils/s3';
import { originStorage } from './utils/origin-storage';
@ -106,9 +108,11 @@ app.get('/uploads/:filename', async (req: Request, res: Response, next: NextFunc
// API Routes
app.use('/api/v1/auth', authRoutes);
app.use('/api/v1/assets', assetRoutes);
app.use('/api/v1/assets', notificationRoutes);
app.use('/api/v1/organizations', orgRoutes);
app.use('/api/v1/legal', legalRoutes);
app.use('/api/v1/ecosystem', ecosystemRoutes);
app.use('/api/v1/taxonomy', taxonomyRoutes);
app.get('/api/v1/health', (req: Request, res: Response) => {
res.status(200).json({ status: 'success', message: 'API is fully functional and real.' });

View File

@ -59,6 +59,11 @@ export class AssetController {
fileUrl = req.body.url;
}
let verticalIds = req.body.verticalIds;
if (typeof verticalIds === 'string' && verticalIds.trim()) {
try { verticalIds = JSON.parse(verticalIds); } catch { verticalIds = verticalIds.split(',').map((id: string) => id.trim()).filter(Boolean); }
}
const assetData = {
title: req.body.title || (req.file ? req.file.originalname : 'URL Asset'),
type: req.body.type || (isUrlAsset ? 'url' : req.file!.mimetype),
@ -75,6 +80,7 @@ export class AssetController {
thumbnailUrl: req.body.thumbnailUrl || null,
problemStatement: req.body.problemStatement || null,
solution: req.body.solution || null,
verticalIds,
shares,
sharedOrgIds: req.body.sharedOrgIds || null,
};
@ -87,7 +93,15 @@ export class AssetController {
public listAssets = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const userContext = req.user ? { role: req.user.role, userId: req.user.userId } : undefined;
const assets = await this.assetService.getAssets(userContext);
const filters = {
search: req.query.search as string,
verticalIds: req.query.verticalIds ? (req.query.verticalIds as string).split(',') : undefined,
contentTypes: req.query.contentTypes ? (req.query.contentTypes as string).split(',') : undefined,
subcategories: req.query.subcategories ? (req.query.subcategories as string).split(',') : undefined,
tags: req.query.tags ? (req.query.tags as string).split(',') : undefined,
sortBy: req.query.sortBy as any,
};
const assets = await this.assetService.getAssets(userContext, filters);
res.status(200).json(assets);
} catch(err) { next(err); }
}

View File

@ -0,0 +1,72 @@
import { Response, NextFunction } from 'express';
import prisma from '../utils/db';
import { AuthRequest } from '../middleware/auth.middleware';
import { MailService } from '../services/mail.service';
export class NotificationController {
private mailService = new MailService();
public sendAssetAnnouncement = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { title, message, targetOrgIds, assetIds } = req.body;
if (!title || !message) {
return res.status(400).json({ error: 'Title and message are required' });
}
let senderEmail = 'admin@tech4biz.com';
if (req.user?.userId) {
const adminUser = await prisma.user.findUnique({ where: { id: req.user.userId }, select: { email: true } });
if (adminUser?.email) senderEmail = adminUser.email;
}
// Log notification record
const notification = await prisma.assetNotification.create({
data: {
title,
message,
sentBy: senderEmail,
targetOrgIds: Array.isArray(targetOrgIds) ? targetOrgIds : ['ALL'],
assetIds: Array.isArray(assetIds) ? assetIds : [],
}
});
// Find recipient users
const userWhere: any = { role: 'PARTNER_USER' };
if (Array.isArray(targetOrgIds) && targetOrgIds.length > 0 && !targetOrgIds.includes('ALL')) {
userWhere.organizationId = { in: targetOrgIds };
}
const partnerUsers = await prisma.user.findMany({
where: userWhere,
select: { email: true }
});
const recipientEmails = partnerUsers.map(u => u.email).filter(Boolean);
// Trigger email notifications asynchronously via mailService
if (recipientEmails.length > 0) {
this.mailService.sendCustomAnnouncement({
recipients: recipientEmails,
subject: `📢 Asset Announcement: ${title}`,
messageBody: message,
}).catch(err => console.error('Failed to dispatch announcement emails:', err));
}
res.status(201).json({
message: 'Notification sent successfully',
recipientsCount: recipientEmails.length,
notification,
});
} catch (err) { next(err); }
};
public getNotificationLogs = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const logs = await prisma.assetNotification.findMany({
orderBy: { createdAt: 'desc' },
take: 50,
});
res.status(200).json(logs);
} catch (err) { next(err); }
};
}

View File

@ -0,0 +1,120 @@
import { Request, Response, NextFunction } from 'express';
import prisma from '../utils/db';
import { AuthRequest } from '../middleware/auth.middleware';
export class TaxonomyController {
// Public/Authenticated: Get all active verticals
public getVerticals = async (req: Request, res: Response, next: NextFunction) => {
try {
const verticals = await prisma.vertical.findMany({
where: { isActive: true },
include: {
_count: {
select: { assets: true }
}
},
orderBy: { orderIndex: 'asc' },
});
res.status(200).json(verticals);
} catch (err) { next(err); }
};
// Public/Authenticated: Get taxonomy metadata with real-time asset counts
public getTaxonomyMeta = async (req: Request, res: Response, next: NextFunction) => {
try {
const [verticals, assets] = await Promise.all([
prisma.vertical.findMany({
where: { isActive: true },
include: { _count: { select: { assets: true } } },
orderBy: { orderIndex: 'asc' },
}),
prisma.asset.findMany({
select: {
categoryId: true,
subcategory: true,
contentType: true,
tags: true,
}
})
]);
const categoryCounts: Record<string, number> = {};
const subcategoryCounts: Record<string, number> = {};
const contentTypeCounts: Record<string, number> = {};
const tagCounts: Record<string, number> = {};
assets.forEach(a => {
if (a.categoryId) {
categoryCounts[a.categoryId] = (categoryCounts[a.categoryId] || 0) + 1;
}
if (a.subcategory) {
subcategoryCounts[a.subcategory] = (subcategoryCounts[a.subcategory] || 0) + 1;
}
if (a.contentType) {
contentTypeCounts[a.contentType] = (contentTypeCounts[a.contentType] || 0) + 1;
}
if (Array.isArray(a.tags)) {
a.tags.forEach(t => {
if (t) tagCounts[t] = (tagCounts[t] || 0) + 1;
});
}
});
res.status(200).json({
verticals,
categories: Object.entries(categoryCounts).map(([name, count]) => ({ name, count })),
subcategories: Object.entries(subcategoryCounts).map(([name, count]) => ({ name, count })),
contentTypes: Object.entries(contentTypeCounts).map(([name, count]) => ({ name, count })),
tags: Object.entries(tagCounts).map(([name, count]) => ({ name, count })),
totalAssets: assets.length,
});
} catch (err) { next(err); }
};
// Admin: Create Vertical
public createVertical = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { name, icon, description, color } = req.body;
if (!name) return res.status(400).json({ error: 'Name is required' });
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)+/g, '');
const vertical = await prisma.vertical.create({
data: { name, slug, icon, description, color }
});
res.status(201).json(vertical);
} catch (err) { next(err); }
};
// Admin: Update Vertical
public updateVertical = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const { name, icon, description, color, isActive, orderIndex } = req.body;
const data: any = {};
if (name !== undefined) {
data.name = name;
data.slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)+/g, '');
}
if (icon !== undefined) data.icon = icon;
if (description !== undefined) data.description = description;
if (color !== undefined) data.color = color;
if (isActive !== undefined) data.isActive = isActive;
if (orderIndex !== undefined) data.orderIndex = orderIndex;
const vertical = await prisma.vertical.update({
where: { id },
data,
});
res.status(200).json(vertical);
} catch (err) { next(err); }
};
// Admin: Delete Vertical
public deleteVertical = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
await prisma.vertical.delete({ where: { id } });
res.status(204).send();
} catch (err) { next(err); }
};
}

View File

@ -0,0 +1,11 @@
import { Router } from 'express';
import { NotificationController } from '../controllers/notification.controller';
import { authenticate, requireRole } from '../middleware/auth.middleware';
const router = Router();
const controller = new NotificationController();
router.post('/notify', authenticate, requireRole('ADMIN'), controller.sendAssetAnnouncement);
router.get('/logs', authenticate, requireRole('ADMIN'), controller.getNotificationLogs);
export default router;

View File

@ -0,0 +1,15 @@
import { Router } from 'express';
import { TaxonomyController } from '../controllers/taxonomy.controller';
import { authenticate, requireRole } from '../middleware/auth.middleware';
const router = Router();
const controller = new TaxonomyController();
router.get('/verticals', authenticate, controller.getVerticals);
router.get('/meta', authenticate, controller.getTaxonomyMeta);
router.post('/verticals', authenticate, requireRole('ADMIN'), controller.createVertical);
router.put('/verticals/:id', authenticate, requireRole('ADMIN'), controller.updateVertical);
router.delete('/verticals/:id', authenticate, requireRole('ADMIN'), controller.deleteVertical);
export default router;

View File

@ -55,7 +55,7 @@ export class AssetService {
}
public async createAsset(data: any) {
const { sharedOrgIds, shares, tags, ...rest } = data;
const { sharedOrgIds, shares, tags, verticalIds, ...rest } = data;
// Parse tags
let parsedTags: string[] = [];
@ -69,10 +69,27 @@ export class AssetService {
}
}
// Parse verticalIds
let parsedVerticalIds: string[] = [];
if (Array.isArray(verticalIds)) {
parsedVerticalIds = verticalIds;
} else if (typeof verticalIds === 'string' && verticalIds.trim()) {
try {
parsedVerticalIds = JSON.parse(verticalIds);
} catch {
parsedVerticalIds = verticalIds.split(',').map((id: string) => id.trim()).filter(Boolean);
}
}
const asset = await prisma.asset.create({
data: {
...rest,
tags: parsedTags,
...(parsedVerticalIds.length > 0 ? {
verticals: {
connect: parsedVerticalIds.map(id => ({ id }))
}
} : {})
}
});
@ -138,53 +155,40 @@ export class AssetService {
return this.getAssetById(asset.id);
}
public async getAssets(userContext?: { role: string; userId: string }) {
public async getAssets(
userContext?: { role: string; userId: string },
filters?: {
search?: string;
verticalIds?: string[];
contentTypes?: string[];
subcategories?: string[];
tags?: string[];
sortBy?: 'newest' | 'oldest' | 'title_asc' | 'title_desc' | 'type';
}
) {
if (!userContext) {
return [];
}
if (userContext.role === 'ADMIN') {
return await prisma.asset.findMany({
include: {
sharedWith: {
include: {
organization: {
select: { id: true, name: true }
},
user: {
select: { id: true, email: true }
}
}
},
downloadRequests: {
include: {
user: {
select: { id: true, email: true }
}
}
}
},
orderBy: { createdAt: 'desc' }
const whereClause: any = {};
if (userContext.role !== 'ADMIN') {
// For clients/partners, find user organization first
const user = await prisma.user.findUnique({
where: { id: userContext.userId }
});
}
// For clients/partners, find user organization first
const user = await prisma.user.findUnique({
where: { id: userContext.userId }
});
if (!user || !user.organizationId) {
return [];
}
if (!user || !user.organizationId) {
return [];
}
// Support comma-separated multiple groups
const userGroups = user.partnerGroup
? user.partnerGroup.split(',').map(s => s.trim().toLowerCase())
: [];
// Support comma-separated multiple groups
const userGroups = user.partnerGroup
? user.partnerGroup.split(',').map(s => s.trim().toLowerCase())
: [];
const whereClause: any = {
status: 'published',
OR: [
whereClause.status = 'published';
whereClause.OR = [
{
sharedWith: {
some: {
@ -196,25 +200,81 @@ export class AssetService {
}
}
}
]
};
];
if (userGroups.length > 0) {
whereClause.OR.push({
assetGroups: {
some: {
name: {
in: userGroups,
mode: 'insensitive'
if (userGroups.length > 0) {
whereClause.OR.push({
assetGroups: {
some: {
name: {
in: userGroups,
mode: 'insensitive'
}
}
}
});
}
}
// Apply Filter Criteria (Additive AND logic)
const andConditions: any[] = [];
if (filters?.search && filters.search.trim()) {
const q = filters.search.trim();
andConditions.push({
OR: [
{ title: { contains: q, mode: 'insensitive' } },
{ description: { contains: q, mode: 'insensitive' } },
{ subcategory: { contains: q, mode: 'insensitive' } },
{ categoryId: { contains: q, mode: 'insensitive' } },
{ tags: { has: q } },
]
});
}
if (filters?.verticalIds && filters.verticalIds.length > 0) {
andConditions.push({
verticals: {
some: {
id: { in: filters.verticalIds }
}
}
});
}
if (filters?.contentTypes && filters.contentTypes.length > 0) {
andConditions.push({
contentType: { in: filters.contentTypes }
});
}
if (filters?.subcategories && filters.subcategories.length > 0) {
andConditions.push({
subcategory: { in: filters.subcategories }
});
}
if (filters?.tags && filters.tags.length > 0) {
andConditions.push({
tags: { hasSome: filters.tags }
});
}
if (andConditions.length > 0) {
whereClause.AND = andConditions;
}
// Order By
let orderBy: any = { createdAt: 'desc' };
if (filters?.sortBy === 'oldest') orderBy = { createdAt: 'asc' };
else if (filters?.sortBy === 'title_asc') orderBy = { title: 'asc' };
else if (filters?.sortBy === 'title_desc') orderBy = { title: 'desc' };
else if (filters?.sortBy === 'type') orderBy = { type: 'asc' };
return await prisma.asset.findMany({
where: whereClause,
include: {
verticals: true,
sharedWith: {
include: {
organization: {
@ -225,11 +285,15 @@ export class AssetService {
}
}
},
downloadRequests: {
downloadRequests: userContext.role === 'ADMIN' ? {
include: {
user: { select: { id: true, email: true } }
}
} : {
where: { userId: userContext.userId }
}
},
orderBy: { createdAt: 'desc' }
orderBy,
});
}
@ -259,7 +323,7 @@ export class AssetService {
}
public async updateAsset(id: string, data: any) {
const { sharedOrgIds, shares, tags, ...rest } = data;
const { shares, sharedOrgIds, tags, verticalIds, ...rest } = data;
const updateData: any = { ...rest };
@ -277,6 +341,22 @@ export class AssetService {
updateData.tags = parsedTags;
}
if (verticalIds !== undefined) {
let parsedVerticalIds: string[] = [];
if (Array.isArray(verticalIds)) {
parsedVerticalIds = verticalIds;
} else if (typeof verticalIds === 'string') {
try {
parsedVerticalIds = JSON.parse(verticalIds);
} catch {
parsedVerticalIds = verticalIds.split(',').map((v: string) => v.trim()).filter(Boolean);
}
}
updateData.verticals = {
set: parsedVerticalIds.map(vid => ({ id: vid }))
};
}
await prisma.asset.update({
where: { id },
data: updateData

View File

@ -66,4 +66,26 @@ export class MailService {
throw err;
}
}
public async sendCustomAnnouncement(options: { recipients: string[]; subject: string; messageBody: string }) {
if (!options.recipients || options.recipients.length === 0) return;
const mailOptions = {
from: process.env.SMTP_FROM || '"Tech4Biz Portal" <noreply@tech4biz.com>',
to: options.recipients.join(', '),
subject: options.subject,
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 24px; border: 1px solid #e2e8f0; border-radius: 8px; background-color: #ffffff;">
<h2 style="color: #0f172a; margin-top: 0;">${options.subject}</h2>
<div style="font-size: 14px; color: #334155; line-height: 1.6; white-space: pre-wrap; background: #f8fafc; padding: 16px; border-radius: 6px; border: 1px solid #e2e8f0;">${options.messageBody}</div>
<p style="font-size: 12px; color: #64748b; margin-top: 24px;">Sent via Tech4Biz Channel Partner Platform</p>
</div>
`
};
try {
await this.transporter.sendMail(mailOptions);
console.log(`[SMTP] Announcement email sent to ${options.recipients.length} recipients.`);
} catch (err) {
console.error('[SMTP ERROR] Failed to send announcement email:', err);
}
}
}

View File

@ -0,0 +1,464 @@
import React, { useState } from 'react';
import { X, Shield, Plus, Trash2, Send, Tag, CheckCircle2, AlertCircle, Bell } from 'lucide-react';
import type { TaxonomyMeta, Organization, Asset } from '../../../types/assets';
import { createVertical, deleteVertical, sendAssetAnnouncement } from '../../../services/assets-api';
interface AssetAdminManagerModalProps {
isOpen: boolean;
onClose: () => void;
meta: TaxonomyMeta | null;
organizations: Organization[];
allAssets?: Asset[];
onRefreshMeta: () => void;
}
export const AssetAdminManagerModal: React.FC<AssetAdminManagerModalProps> = ({
isOpen,
onClose,
meta,
organizations,
allAssets = [],
onRefreshMeta,
}) => {
const [activeTab, setActiveTab] = useState<'verticals' | 'taxonomy' | 'announcements'>('verticals');
const [loading, setLoading] = useState(false);
const [statusMsg, setStatusMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
// Inspect filter state for Taxonomy Stats interactive list
const [inspectFilter, setInspectFilter] = useState<{ type: 'Subcategory' | 'Vertical'; name: string; id?: string } | null>(null);
// New Vertical Form State
const [newVerticalName, setNewVerticalName] = useState('');
const [newVerticalColor, setNewVerticalColor] = useState('#3b82f6');
const [newVerticalDesc, setNewVerticalDesc] = useState('');
// Announcement Form State
const [announcementTitle, setAnnouncementTitle] = useState('');
const [announcementMsg, setAnnouncementMsg] = useState('');
const [selectedOrgId, setSelectedOrgId] = useState<string>('ALL');
if (!isOpen) return null;
const matchingAssets = (allAssets || []).filter(asset => {
if (!inspectFilter) return false;
if (inspectFilter.type === 'Subcategory') {
return (asset.subcategory || asset.categoryId || '').toLowerCase() === inspectFilter.name.toLowerCase();
}
if (inspectFilter.type === 'Vertical') {
return asset.verticals?.some(v => v.id === inspectFilter.id || v.name.toLowerCase() === inspectFilter.name.toLowerCase());
}
return false;
});
const handleCreateVertical = async (e: React.FormEvent) => {
e.preventDefault();
if (!newVerticalName.trim()) return;
setLoading(true);
try {
await createVertical({
name: newVerticalName.trim(),
color: newVerticalColor,
description: newVerticalDesc.trim() || undefined,
icon: 'Shield',
});
setStatusMsg({ type: 'success', text: `Vertical "${newVerticalName}" created successfully!` });
setNewVerticalName('');
setNewVerticalDesc('');
onRefreshMeta();
} catch (err: any) {
setStatusMsg({ type: 'error', text: err.response?.data?.error || 'Failed to create vertical' });
} finally {
setLoading(false);
}
};
const handleDeleteVertical = async (id: string, name: string) => {
if (!window.confirm(`Are you sure you want to delete vertical "${name}"?`)) return;
setLoading(true);
try {
await deleteVertical(id);
setStatusMsg({ type: 'success', text: `Vertical "${name}" deleted` });
onRefreshMeta();
} catch (err: any) {
setStatusMsg({ type: 'error', text: 'Failed to delete vertical' });
} finally {
setLoading(false);
}
};
const handleSendAnnouncement = async (e: React.FormEvent) => {
e.preventDefault();
if (!announcementTitle.trim() || !announcementMsg.trim()) return;
setLoading(true);
try {
await sendAssetAnnouncement({
title: announcementTitle.trim(),
message: announcementMsg.trim(),
targetOrgIds: selectedOrgId === 'ALL' ? ['ALL'] : [selectedOrgId],
});
setStatusMsg({ type: 'success', text: 'Announcement dispatched to partner organizations!' });
setAnnouncementTitle('');
setAnnouncementMsg('');
} catch (err: any) {
setStatusMsg({ type: 'error', text: err.response?.data?.error || 'Failed to send announcement' });
} finally {
setLoading(false);
}
};
return (
<div className="fixed inset-0 z-50 overflow-y-auto bg-slate-900/60 backdrop-blur-sm flex items-center justify-center p-4">
<div className="bg-white dark:bg-slate-900 rounded-2xl border border-slate-200 dark:border-slate-800 shadow-2xl w-full max-w-3xl overflow-hidden flex flex-col max-h-[85vh]">
{/* Modal Header */}
<div className="px-6 py-5 border-b border-slate-200 dark:border-slate-800 flex items-center justify-between bg-slate-50/50 dark:bg-slate-900/50">
<div className="flex items-center gap-3">
<div className="p-2 rounded-xl bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900 shadow-md">
<Shield className="w-5 h-5" />
</div>
<div>
<h2 className="text-lg font-bold text-slate-900 dark:text-slate-100">
Taxonomy & Partner Announcements Control Panel
</h2>
<p className="text-xs text-slate-500 dark:text-slate-400">
Manage industry verticals, taxonomy metadata, and partner communications
</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Tab Navigation */}
<div className="flex border-b border-slate-200 dark:border-slate-800 bg-slate-100/50 dark:bg-slate-800/50 px-6">
<button
onClick={() => { setActiveTab('verticals'); setStatusMsg(null); }}
className={`px-4 py-3 text-xs font-bold border-b-2 flex items-center gap-2 transition-colors ${
activeTab === 'verticals'
? 'border-slate-900 text-slate-900 dark:border-slate-100 dark:text-slate-100'
: 'border-transparent text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
}`}
>
<Shield className="w-4 h-4" />
Industry Verticals ({meta?.verticals.length || 0})
</button>
<button
onClick={() => { setActiveTab('taxonomy'); setStatusMsg(null); }}
className={`px-4 py-3 text-xs font-bold border-b-2 flex items-center gap-2 transition-colors ${
activeTab === 'taxonomy'
? 'border-slate-900 text-slate-900 dark:border-slate-100 dark:text-slate-100'
: 'border-transparent text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
}`}
>
<Tag className="w-4 h-4" />
Taxonomy Stats ({meta?.totalAssets || 0} Assets)
</button>
<button
onClick={() => { setActiveTab('announcements'); setStatusMsg(null); }}
className={`px-4 py-3 text-xs font-bold border-b-2 flex items-center gap-2 transition-colors ${
activeTab === 'announcements'
? 'border-slate-900 text-slate-900 dark:border-slate-100 dark:text-slate-100'
: 'border-transparent text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
}`}
>
<Bell className="w-4 h-4" />
Partner Announcements
</button>
</div>
{/* Status Message */}
{statusMsg && (
<div
className={`px-6 py-3 text-xs font-medium flex items-center gap-2 ${
statusMsg.type === 'success'
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-300'
: 'bg-rose-50 text-rose-700 dark:bg-rose-950/50 dark:text-rose-300'
}`}
>
{statusMsg.type === 'success' ? <CheckCircle2 className="w-4 h-4" /> : <AlertCircle className="w-4 h-4" />}
{statusMsg.text}
</div>
)}
{/* Modal Body */}
<div className="p-6 overflow-y-auto flex-1 custom-scrollbar">
{/* TAB 1: Verticals */}
{activeTab === 'verticals' && (
<div className="space-y-6">
{/* Create Vertical Form */}
<form onSubmit={handleCreateVertical} className="p-4 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700 space-y-4">
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-700 dark:text-slate-300 flex items-center gap-2">
<Plus className="w-4 h-4 text-slate-700 dark:text-slate-300" />
Add New Industry Vertical
</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="sm:col-span-2">
<label className="block text-[11px] font-semibold text-slate-600 dark:text-slate-400 mb-1">
Vertical Name
</label>
<input
type="text"
placeholder="e.g. CleanTech & Renewables"
value={newVerticalName}
onChange={e => setNewVerticalName(e.target.value)}
className="w-full px-3 py-2 text-xs rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:ring-2 focus:ring-slate-400"
/>
</div>
<div>
<label className="block text-[11px] font-semibold text-slate-600 dark:text-slate-400 mb-1">
Badge Color
</label>
<div className="flex items-center gap-2">
<input
type="color"
value={newVerticalColor}
onChange={e => setNewVerticalColor(e.target.value)}
className="w-9 h-9 p-0.5 rounded border border-slate-200 dark:border-slate-700 cursor-pointer"
/>
<input
type="text"
value={newVerticalColor}
onChange={e => setNewVerticalColor(e.target.value)}
className="w-full px-2 py-1.5 text-xs font-mono rounded border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800"
/>
</div>
</div>
</div>
<div>
<input
type="text"
placeholder="Short description of this vertical domain..."
value={newVerticalDesc}
onChange={e => setNewVerticalDesc(e.target.value)}
className="w-full px-3 py-2 text-xs rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100"
/>
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={loading || !newVerticalName.trim()}
className="px-4 py-2 rounded-lg bg-slate-900 hover:bg-slate-800 dark:bg-slate-100 dark:hover:bg-white text-white dark:text-slate-900 text-xs font-bold disabled:opacity-50 transition-all flex items-center gap-1.5"
>
<Plus className="w-4 h-4" />
Create Vertical
</button>
</div>
</form>
{/* Verticals Table */}
<div className="space-y-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400">
Active Verticals ({meta?.verticals.length || 0})
</h3>
<div className="divide-y divide-slate-100 dark:divide-slate-800 border border-slate-200 dark:border-slate-800 rounded-xl overflow-hidden">
{(meta?.verticals || []).map(v => (
<div key={v.id} className="p-3 bg-white dark:bg-slate-900 flex items-center justify-between text-xs">
<div className="flex items-center gap-3">
<span className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: v.color || '#6366f1' }} />
<div>
<div className="font-semibold text-slate-900 dark:text-slate-100 flex items-center gap-2">
{v.name}
<span className="text-[10px] font-mono px-2 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 text-slate-500">
{v._count?.assets ?? 0} assets
</span>
</div>
{v.description && <div className="text-[11px] text-slate-400">{v.description}</div>}
</div>
</div>
<button
onClick={() => handleDeleteVertical(v.id, v.name)}
className="p-1.5 text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-950/50 rounded-lg transition-colors"
title="Delete Vertical"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
</div>
</div>
)}
{/* TAB 2: Taxonomy Stats */}
{activeTab === 'taxonomy' && (
<div className="space-y-6">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="p-4 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800">
<div className="text-2xl font-bold text-slate-900 dark:text-slate-100">{meta?.totalAssets || 0}</div>
<div className="text-xs text-slate-500 font-medium">Total Catalog Assets</div>
</div>
<div className="p-4 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800">
<div className="text-2xl font-bold text-emerald-600">{meta?.verticals.length || 0}</div>
<div className="text-xs text-slate-500 font-medium font-mono">Industry Verticals</div>
</div>
<div className="p-4 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800">
<div className="text-2xl font-bold text-amber-500">{meta?.subcategories.length || 0}</div>
<div className="text-xs text-slate-500 font-medium">Subcategories</div>
</div>
<div className="p-4 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800">
<div className="text-2xl font-bold text-cyan-600">{meta?.tags.length || 0}</div>
<div className="text-xs text-slate-500 font-medium">Unique Tags</div>
</div>
</div>
<div>
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-3">
Subcategory Breakdown (Click to Inspect Records)
</h4>
<div className="flex flex-wrap gap-2">
{(meta?.subcategories || []).map(s => (
<button
key={s.name}
onClick={() => setInspectFilter({ type: 'Subcategory', name: s.name })}
className={`px-3 py-1.5 rounded-lg border text-xs font-semibold transition-all cursor-pointer flex items-center gap-1.5 ${
inspectFilter?.name === s.name
? 'bg-slate-900 text-white border-slate-900 dark:bg-slate-100 dark:text-slate-900'
: 'bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-700 dark:text-slate-300 hover:border-slate-400'
}`}
>
<span>{s.name}</span>
<span className="font-extrabold px-1.5 py-0.2 bg-slate-100 dark:bg-slate-700 rounded text-[10px]">
{s.count}
</span>
</button>
))}
</div>
</div>
<div>
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-3">
Vertical Domain Distribution
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{(meta?.verticals || []).map(v => (
<button
key={v.id}
onClick={() => setInspectFilter({ type: 'Vertical', name: v.name, id: v.id })}
className={`p-3 rounded-xl border text-left transition-all cursor-pointer flex items-center justify-between ${
inspectFilter?.name === v.name
? 'bg-slate-900 text-white border-slate-900 dark:bg-slate-100 dark:text-slate-900'
: 'bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-800 dark:text-slate-200 hover:border-slate-400'
}`}
>
<div className="flex items-center gap-2.5">
<span className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: v.color || '#3b82f6' }} />
<span className="font-bold text-xs">{v.name}</span>
</div>
<span className="text-xs font-mono font-bold px-2 py-0.5 rounded bg-slate-100 dark:bg-slate-700">
{v._count?.assets ?? 0} assets
</span>
</button>
))}
</div>
</div>
{/* Inspect Filter Asset List Details */}
{inspectFilter && (
<div className="p-4 rounded-xl bg-slate-100 dark:bg-slate-800/80 border border-slate-300 dark:border-slate-700 space-y-3">
<div className="flex items-center justify-between">
<h5 className="text-xs font-extrabold text-slate-900 dark:text-slate-100 flex items-center gap-2">
<Tag className="w-4 h-4 text-amber-500" />
Inspecting {inspectFilter.type}: <span className="underline">{inspectFilter.name}</span>
</h5>
<button
onClick={() => setInspectFilter(null)}
className="text-[11px] font-bold text-slate-500 hover:text-slate-900 dark:hover:text-slate-100"
>
Clear Selection
</button>
</div>
<div className="divide-y divide-slate-200 dark:divide-slate-700/60 max-h-48 overflow-y-auto custom-scrollbar bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-700">
{matchingAssets.length === 0 ? (
<div className="p-4 text-xs text-center text-slate-500">No assets tagged with this {inspectFilter.type.toLowerCase()} yet.</div>
) : (
matchingAssets.map(asset => (
<div key={asset.id} className="p-2.5 flex items-center justify-between text-xs hover:bg-slate-50 dark:hover:bg-slate-800/50">
<div className="min-w-0 flex-1 pr-3">
<div className="font-bold text-slate-900 dark:text-slate-100 truncate">{asset.title}</div>
<div className="text-[10px] text-slate-500 font-mono truncate">{asset.subcategory || asset.type} {asset.url}</div>
</div>
<a
href={asset.url}
target="_blank"
rel="noreferrer"
className="px-2.5 py-1 text-[10px] font-bold bg-slate-100 dark:bg-slate-800 border border-slate-300 dark:border-slate-700 rounded text-slate-800 dark:text-slate-200 hover:bg-slate-200"
>
View Asset
</a>
</div>
))
)}
</div>
</div>
)}
</div>
)}
{/* TAB 3: Announcements */}
{activeTab === 'announcements' && (
<form onSubmit={handleSendAnnouncement} className="space-y-4">
<div>
<label className="block text-xs font-bold text-slate-700 dark:text-slate-300 mb-1">
Target Partner Organization
</label>
<select
value={selectedOrgId}
onChange={e => setSelectedOrgId(e.target.value)}
className="w-full px-3 py-2 text-xs rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100"
>
<option value="ALL">All Partner Organizations (Broadcast)</option>
{organizations.map(org => (
<option key={org.id} value={org.id}>{org.name}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-bold text-slate-700 dark:text-slate-300 mb-1">
Announcement Title
</label>
<input
type="text"
placeholder="e.g. New Cybersecurity Case Studies & MVPs Released"
value={announcementTitle}
onChange={e => setAnnouncementTitle(e.target.value)}
className="w-full px-3 py-2 text-xs rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100"
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-700 dark:text-slate-300 mb-1">
Message Body
</label>
<textarea
rows={5}
placeholder="Write announcement details for partners..."
value={announcementMsg}
onChange={e => setAnnouncementMsg(e.target.value)}
className="w-full px-3 py-2 text-xs rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 custom-scrollbar"
/>
</div>
<div className="flex justify-end pt-2">
<button
type="submit"
disabled={loading || !announcementTitle.trim() || !announcementMsg.trim()}
className="px-5 py-2.5 rounded-xl bg-slate-900 hover:bg-slate-800 dark:bg-slate-100 dark:hover:bg-white text-white dark:text-slate-900 text-xs font-bold shadow-md disabled:opacity-50 transition-all flex items-center gap-2"
>
<Send className="w-4 h-4" />
Dispatch Announcement
</button>
</div>
</form>
)}
</div>
</div>
</div>
);
};

View File

@ -45,7 +45,7 @@ interface AssetCardProps {
onDownload: (asset: Asset) => void;
onRequestDownload: (asset: Asset) => void;
isSelected?: boolean;
onToggleSelect?: (assetId: string) => void;
onToggleSelect?: (assetId: string, event?: React.MouseEvent) => void;
isExpanded?: boolean;
onToggleExpand?: () => void;
isRecommended?: boolean;
@ -170,8 +170,11 @@ export const AssetCard: React.FC<AssetCardProps> = ({
<input
type="checkbox"
checked={isSelected}
onChange={() => onToggleSelect(asset.id)}
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"
onClick={(e) => e.stopPropagation()}
onChange={(e) => onToggleSelect(asset.id, e as unknown as React.MouseEvent)}
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 transition-opacity duration-200 ${
isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
}`}
/>
)}
{isRecommended && (
@ -297,10 +300,10 @@ export const AssetCard: React.FC<AssetCardProps> = ({
url={getFullAssetUrl(asset.url)}
fallback={
<div className="w-full h-full bg-white border border-slate-200 text-left select-none relative overflow-hidden p-3.5 flex flex-col justify-between">
<div className="absolute top-0 right-0 left-0 h-1 bg-indigo-600" />
<div className="absolute top-0 right-0 left-0 h-1 bg-slate-800" />
<div className="flex justify-between items-center text-[7px] text-slate-400 font-bold tracking-wide border-b border-slate-100 pb-1.5">
<span>PARTNER ASSET LIBRARY</span>
<span className="font-extrabold text-indigo-650">DOCX</span>
<span className="font-extrabold text-slate-800">DOCX</span>
</div>
<div className="space-y-2 flex-grow mt-3">
<h4 className="text-[11px] font-extrabold text-slate-900 leading-snug font-sans line-clamp-2">
@ -380,7 +383,7 @@ export const AssetCard: React.FC<AssetCardProps> = ({
</div>
{/* Browser Body Web Mockup */}
<div className="flex-1 bg-gradient-to-tr from-slate-950 via-slate-900 to-indigo-950 p-3 flex flex-col justify-between relative overflow-hidden">
<div className="flex-1 bg-slate-950 p-3 flex flex-col justify-between relative overflow-hidden">
{/* Decorative Grid Pattern */}
<div className="absolute inset-0 bg-[linear-gradient(to_right,#8080800a_1px,transparent_1px),linear-gradient(to_bottom,#8080800a_1px,transparent_1px)] bg-[size:10px_10px]" />
@ -388,7 +391,7 @@ export const AssetCard: React.FC<AssetCardProps> = ({
<div className="space-y-2 mt-1.5 relative z-10">
{/* Mock Navbar */}
<div className="flex justify-between items-center bg-white/[0.03] border border-white/5 rounded px-2 py-0.5">
<div className="w-8 h-1.5 bg-indigo-400/30 rounded" />
<div className="w-8 h-1.5 bg-slate-700 rounded" />
<div className="flex gap-1">
<div className="w-4 h-1 bg-white/20 rounded" />
<div className="w-4 h-1 bg-white/20 rounded" />
@ -396,7 +399,7 @@ export const AssetCard: React.FC<AssetCardProps> = ({
</div>
{/* Mock Page Content */}
<div className="space-y-1 pt-1">
<div className="h-2.5 bg-gradient-to-r from-indigo-400 to-cyan-400 rounded w-3/4" />
<div className="h-2.5 bg-slate-700 rounded w-3/4" />
<div className="h-1.5 bg-white/20 rounded w-11/12" />
<div className="h-1.5 bg-white/10 rounded w-5/6" />
</div>
@ -404,7 +407,7 @@ export const AssetCard: React.FC<AssetCardProps> = ({
{/* Mock Card Domain Display */}
<div className="relative z-10 bg-slate-900/80 border border-slate-800/80 rounded-lg p-2 flex items-center gap-2 select-none">
<div className="w-6 h-6 rounded bg-indigo-650 flex items-center justify-center text-[10px] font-black text-white shrink-0">
<div className="w-6 h-6 rounded bg-slate-800 flex items-center justify-center text-[10px] font-black text-white shrink-0">
{getFriendlyHostname(asset.url).charAt(0).toUpperCase()}
</div>
<div className="flex-1 min-w-0 text-left">
@ -465,12 +468,12 @@ export const AssetCard: React.FC<AssetCardProps> = ({
) : isWord ? (
<div className="w-full h-full bg-white border border-slate-200 text-left select-none relative overflow-hidden p-3.5 flex flex-col justify-between">
{/* Document Margins decorative elements */}
<div className="absolute top-0 right-0 left-0 h-1 bg-indigo-600" />
<div className="absolute top-0 right-0 left-0 h-1 bg-slate-800" />
{/* Word Header */}
<div className="flex justify-between items-center text-[7px] text-slate-400 font-bold tracking-wide border-b border-slate-100 pb-1.5">
<span>PARTNER ASSET LIBRARY</span>
<span className="font-extrabold text-indigo-650">DOCX</span>
<span className="font-extrabold text-slate-800">DOCX</span>
</div>
{/* Page Title & Body Text Mockup */}
@ -568,9 +571,22 @@ export const AssetCard: React.FC<AssetCardProps> = ({
</div>
<div className="space-y-1 mb-4 flex-grow flex flex-col">
<div className="flex items-center gap-1.5 mb-2">
<div className="flex flex-wrap items-center gap-1.5 mb-2">
{asset.verticals && asset.verticals.length > 0 && (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-bold tracking-wider border shrink-0"
style={{
backgroundColor: `${asset.verticals[0].color || '#6366f1'}15`,
borderColor: `${asset.verticals[0].color || '#6366f1'}40`,
color: asset.verticals[0].color || '#6366f1',
}}
>
<span className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: asset.verticals[0].color || '#6366f1' }} />
{asset.verticals[0].name}
</span>
)}
<div className="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold text-ink-500 uppercase tracking-wider bg-ink-50 border border-ink-200">
{asset.categoryId || 'General'}
{asset.subcategory || asset.categoryId || 'General'}
</div>
{!asset.isDownloadable && (
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-bold text-ink-600 bg-ink-100 border border-ink-200">

View File

@ -0,0 +1,189 @@
import React from 'react';
import { Download, ExternalLink, FileText, Globe, Sparkles } from 'lucide-react';
import type { Asset } from '../../../types/assets';
interface AssetTableViewProps {
assets: Asset[];
selectedIds: string[];
recommendedIds?: string[];
onToggleSelect: (id: string, event: React.MouseEvent) => void;
onSelectAll: () => void;
onOpenAsset: (asset: Asset) => void;
onRequestDownload: (id: string) => void;
userRole?: string;
}
export const AssetTableView: React.FC<AssetTableViewProps> = ({
assets,
selectedIds,
recommendedIds = [],
onToggleSelect,
onSelectAll,
onOpenAsset,
onRequestDownload,
}) => {
const allSelected = assets.length > 0 && selectedIds.length === assets.length;
return (
<div className="w-full bg-white dark:bg-slate-900/90 rounded-xl border border-slate-200 dark:border-slate-800 shadow-sm overflow-hidden transition-all">
<div className="overflow-x-auto custom-scrollbar">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-slate-50 dark:bg-slate-800/80 border-b border-slate-200 dark:border-slate-800 text-[11px] font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400">
<th className="py-3 px-4 w-10">
<input
type="checkbox"
checked={allSelected}
onChange={onSelectAll}
className="rounded border-slate-300 dark:border-slate-600 text-slate-900 dark:text-slate-100 focus:ring-slate-400 cursor-pointer"
/>
</th>
<th className="py-3 px-4 min-w-[280px]">Asset Name & Domain</th>
<th className="py-3 px-4 min-w-[140px]">Vertical / Industry</th>
<th className="py-3 px-4 min-w-[130px]">Type / Format</th>
<th className="py-3 px-4 min-w-[120px]">Subcategory</th>
<th className="py-3 px-4 min-w-[120px]">Created</th>
<th className="py-3 px-4 text-right min-w-[110px]">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-800/60 text-xs">
{assets.map((asset) => {
const isSelected = selectedIds.includes(asset.id);
const isRecommended = recommendedIds.includes(asset.id);
const primaryVertical = asset.verticals && asset.verticals.length > 0 ? asset.verticals[0] : null;
return (
<tr
key={asset.id}
onClick={(e) => onToggleSelect(asset.id, e)}
className={`group hover:bg-slate-100/70 dark:hover:bg-slate-800/60 transition-colors cursor-pointer ${
isSelected
? 'bg-slate-100 dark:bg-slate-800/80 font-medium'
: isRecommended
? 'bg-amber-500/[0.03] dark:bg-amber-500/[0.04]'
: ''
}`}
>
{/* Selection Checkbox */}
<td className="py-3 px-4" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={isSelected}
onChange={(e) => onToggleSelect(asset.id, e as any)}
className="rounded border-slate-300 dark:border-slate-600 text-slate-900 focus:ring-slate-400 cursor-pointer"
/>
</td>
{/* Title & Description & Recommended Badge */}
<td className="py-3 px-4 min-w-[280px]">
<div className="flex items-start gap-2.5">
<div className="p-2 rounded-lg bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300 shrink-0 mt-0.5">
{asset.type === 'url' || asset.type === 'case_study' ? (
<Globe className="w-4 h-4 text-slate-500 dark:text-slate-400" />
) : (
<FileText className="w-4 h-4 text-amber-500" />
)}
</div>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-bold text-slate-900 dark:text-slate-100 group-hover:text-slate-900 dark:group-hover:text-white transition-colors truncate">
{asset.title}
</span>
{isRecommended && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-amber-500 text-slate-950 text-[9px] font-extrabold uppercase tracking-wider shrink-0">
<Sparkles className="w-2.5 h-2.5 fill-slate-950" />
<span>Recommended</span>
</span>
)}
</div>
{asset.description && (
<div className="text-[11px] text-slate-500 dark:text-slate-400 line-clamp-1 mt-0.5">
{asset.description}
</div>
)}
{asset.tags && asset.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{asset.tags.slice(0, 3).map(t => (
<span key={t} className="text-[9px] font-mono px-1.5 py-0.2 rounded bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400">
#{t}
</span>
))}
{asset.tags.length > 3 && (
<span className="text-[9px] text-slate-400">+{asset.tags.length - 3}</span>
)}
</div>
)}
</div>
</div>
</td>
{/* Vertical */}
<td className="py-3 px-4">
{primaryVertical ? (
<span
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-semibold border"
style={{
backgroundColor: `${primaryVertical.color}15`,
borderColor: `${primaryVertical.color}40`,
color: primaryVertical.color || '#94a3b8',
}}
>
<span className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: primaryVertical.color || '#94a3b8' }} />
{primaryVertical.name}
</span>
) : (
<span className="text-slate-400 italic text-[11px]">General</span>
)}
</td>
{/* Type */}
<td className="py-3 px-4">
<span className="inline-block px-2 py-0.5 rounded text-[11px] font-semibold bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 capitalize">
{asset.contentType ? asset.contentType.replace('_', ' ') : asset.type}
</span>
</td>
{/* Subcategory */}
<td className="py-3 px-4 text-slate-700 dark:text-slate-300 font-semibold">
{asset.subcategory || asset.categoryId || '—'}
</td>
{/* Created Date */}
<td className="py-3 px-4 text-slate-500 text-[11px]">
{new Date(asset.createdAt).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</td>
{/* Actions */}
<td className="py-3 px-4 text-right" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-end gap-1.5">
<button
onClick={() => onOpenAsset(asset)}
className="p-1.5 rounded-lg text-slate-500 hover:text-slate-900 dark:hover:text-slate-100 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
title="View Asset"
>
<ExternalLink className="w-4 h-4" />
</button>
{asset.isDownloadable && (
<button
onClick={() => onRequestDownload(asset.id)}
className="p-1.5 rounded-lg text-slate-500 hover:text-emerald-600 dark:hover:text-emerald-400 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
title="Download Asset"
>
<Download className="w-4 h-4" />
</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
};

View File

@ -386,12 +386,12 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
</div>
{/* Browser Content */}
<div className="flex-1 bg-gradient-to-tr from-slate-950 via-slate-900 to-indigo-950 p-6 flex flex-col justify-between relative overflow-hidden">
<div className="flex-1 bg-slate-950 p-6 flex flex-col justify-between relative overflow-hidden">
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808008_1px,transparent_1px),linear-gradient(to_bottom,#80808008_1px,transparent_1px)] bg-[size:16px_16px]" />
<div className="space-y-3 relative z-10 text-left">
<div className="flex justify-between items-center bg-white/[0.02] border border-white/5 rounded-lg px-3 py-1.5">
<div className="w-16 h-2 bg-indigo-400/40 rounded" />
<div className="w-16 h-2 bg-slate-700 rounded" />
<div className="flex gap-1.5">
<div className="w-6 h-1.5 bg-white/20 rounded" />
<div className="w-6 h-1.5 bg-white/20 rounded" />
@ -399,14 +399,14 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
</div>
<div className="space-y-2 pt-2">
<div className="h-5 bg-gradient-to-r from-indigo-400 to-cyan-400 rounded-lg w-5/6" />
<div className="h-5 bg-slate-700 rounded-lg w-5/6" />
<div className="h-2 bg-white/20 rounded w-11/12" />
<div className="h-2 bg-white/15 rounded w-3/4" />
</div>
</div>
<div className="relative z-10 bg-slate-900/80 border border-slate-800/80 rounded-xl p-3.5 flex items-center gap-3 select-none">
<div className="w-10 h-10 rounded-lg bg-indigo-650 flex items-center justify-center text-sm font-black text-white shrink-0 shadow-md shadow-indigo-600/20">
<div className="w-10 h-10 rounded-lg bg-slate-800 flex items-center justify-center text-sm font-black text-white shrink-0 shadow-md">
{(() => {
try {
return new URL(asset.url).hostname.replace('www.', '').charAt(0).toUpperCase();
@ -437,7 +437,7 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
<div className="flex-1 bg-ink-0 border border-ink-200 rounded-2xl p-6 md:p-8 shadow-lg max-w-lg w-full flex flex-col justify-between min-h-[350px]">
<div className="space-y-4 text-left">
<div>
<span className="inline-flex px-2 py-0.5 rounded text-[9px] font-extrabold uppercase tracking-wider text-indigo-650 bg-indigo-50 border border-indigo-100">
<span className="inline-flex px-2 py-0.5 rounded text-[9px] font-extrabold uppercase tracking-wider text-slate-800 bg-slate-100 border border-slate-200">
External Portal Link
</span>
<h3 className="text-lg font-extrabold text-ink-900 font-sans leading-snug mt-1.5">{asset.title}</h3>

View File

@ -65,7 +65,7 @@ export const DocxThumbnail: React.FC<DocxThumbnailProps> = ({ url, fallback }) =
<div ref={parentRef} className="w-full h-full relative overflow-hidden bg-white select-none pointer-events-none">
{loading && (
<div className="absolute inset-0 flex items-center justify-center bg-slate-50 z-10">
<div className="w-5 h-5 rounded-full border-2 border-indigo-600 border-t-transparent animate-spin" />
<div className="w-5 h-5 rounded-full border-2 border-slate-800 border-t-transparent animate-spin" />
</div>
)}
<div

View File

@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import { updateAsset } from '../../../services/assets-api';
import type { Asset } from '../../../types/assets';
import { updateAsset, getVerticals } from '../../../services/assets-api';
import type { Asset, Vertical } from '../../../types/assets';
import Modal from '../../../components/ui/Modal';
import Button from '../../../components/ui/Button';
import { useToast } from '../../../hooks/use-toast';
@ -19,6 +19,8 @@ export const EditAssetModal: React.FC<EditAssetModalProps> = ({
onSuccess
}) => {
const { success, error } = useToast();
const [verticals, setVerticals] = useState<Vertical[]>([]);
const [editVerticalId, setEditVerticalId] = useState<string>('');
const [editTitle, setEditTitle] = useState('');
const [editDescription, setEditDescription] = useState('');
const [editCategory, setEditCategory] = useState('Marketing');
@ -28,6 +30,10 @@ export const EditAssetModal: React.FC<EditAssetModalProps> = ({
const [editIsDownloadable, setEditIsDownloadable] = useState(true);
const [isSavingEdit, setIsSavingEdit] = useState(false);
useEffect(() => {
getVerticals().then(setVerticals).catch(console.error);
}, []);
useEffect(() => {
if (asset) {
setEditTitle(asset.title);
@ -37,6 +43,7 @@ export const EditAssetModal: React.FC<EditAssetModalProps> = ({
setEditTags(asset.tags.join(', '));
setEditGithubUrl(asset.githubUrl || '');
setEditIsDownloadable(asset.isDownloadable);
setEditVerticalId(asset.verticals && asset.verticals.length > 0 ? asset.verticals[0].id : '');
}
}, [asset]);
@ -51,6 +58,7 @@ export const EditAssetModal: React.FC<EditAssetModalProps> = ({
description: editDescription,
categoryId: editCategory,
subcategory: editSubcategory,
verticalIds: editVerticalId ? [editVerticalId] : [],
tags: editTags.split(',').map(t => t.trim()).filter(Boolean),
githubUrl: editGithubUrl,
isDownloadable: editIsDownloadable,
@ -107,7 +115,20 @@ export const EditAssetModal: React.FC<EditAssetModalProps> = ({
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Industry Vertical</label>
<select
value={editVerticalId}
onChange={(e) => setEditVerticalId(e.target.value)}
className="w-full bg-ink-50 border border-ink-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900"
>
<option value="">General (No Domain)</option>
{verticals.map(v => (
<option key={v.id} value={v.id}>{v.name}</option>
))}
</select>
</div>
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Category</label>
<select

View File

@ -0,0 +1,328 @@
import React, { useState } from 'react';
import { X, Filter, Search, RotateCcw, Check, Shield, Cpu, Activity, Landmark, FileCheck, Zap, Leaf, GraduationCap, Factory, Car, ShoppingBag, Link as LinkIcon, FileText, LayoutGrid, Newspaper, Briefcase, PlayCircle, Code, Compass, HelpCircle } from 'lucide-react';
import type { TaxonomyMeta, AssetQueryFilters } from '../../../types/assets';
interface FilterDrawerProps {
isOpen: boolean;
onClose: () => void;
meta: TaxonomyMeta | null;
filters: AssetQueryFilters;
onChangeFilters: (newFilters: AssetQueryFilters) => void;
onClearAll: () => void;
}
const VERTICAL_ICONS: Record<string, React.ReactNode> = {
Shield: <Shield className="w-4 h-4" />,
Cpu: <Cpu className="w-4 h-4" />,
Activity: <Activity className="w-4 h-4" />,
Landmark: <Landmark className="w-4 h-4" />,
FileCheck: <FileCheck className="w-4 h-4" />,
Zap: <Zap className="w-4 h-4" />,
Leaf: <Leaf className="w-4 h-4" />,
GraduationCap: <GraduationCap className="w-4 h-4" />,
Factory: <Factory className="w-4 h-4" />,
Car: <Car className="w-4 h-4" />,
ShoppingBag: <ShoppingBag className="w-4 h-4" />,
Link: <LinkIcon className="w-4 h-4" />,
};
const CONTENT_TYPE_CONFIG: Record<string, { label: string; icon: React.ReactNode; color: string }> = {
case_study: { label: 'Case Studies', icon: <FileText className="w-4 h-4" />, color: 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20' },
showcase: { label: 'Showcases', icon: <LayoutGrid className="w-4 h-4" />, color: 'bg-slate-500/10 text-slate-700 dark:text-slate-300 border-slate-500/20' },
newsletter: { label: 'Newsletters', icon: <Newspaper className="w-4 h-4" />, color: 'bg-amber-500/10 text-amber-600 border-amber-500/20' },
portfolio: { label: 'Portfolios & Decks', icon: <Briefcase className="w-4 h-4" />, color: 'bg-purple-500/10 text-purple-600 border-purple-500/20' },
mvp: { label: 'Live MVPs', icon: <PlayCircle className="w-4 h-4" />, color: 'bg-sky-500/10 text-sky-600 border-sky-500/20' },
document: { label: 'Documents & PDFs', icon: <FileText className="w-4 h-4" />, color: 'bg-slate-500/10 text-slate-600 border-slate-500/20' },
workflow: { label: 'Workflow Automations', icon: <Code className="w-4 h-4" />, color: 'bg-cyan-500/10 text-cyan-600 border-cyan-500/20' },
use_case: { label: 'Technical Use Cases', icon: <Compass className="w-4 h-4" />, color: 'bg-blue-500/10 text-blue-600 border-blue-500/20' },
test_drive: { label: 'Test Drive Resources', icon: <HelpCircle className="w-4 h-4" />, color: 'bg-rose-500/10 text-rose-600 border-rose-500/20' },
};
export const FilterDrawer: React.FC<FilterDrawerProps> = ({
isOpen,
onClose,
meta,
filters,
onChangeFilters,
onClearAll,
}) => {
const [filterQuery, setFilterQuery] = useState('');
if (!isOpen) return null;
const toggleArrayItem = (current: string[] | undefined, item: string): string[] => {
const arr = current || [];
return arr.includes(item) ? arr.filter(x => x !== item) : [...arr, item];
};
const activeCount =
(filters.verticalIds?.length || 0) +
(filters.contentTypes?.length || 0) +
(filters.subcategories?.length || 0) +
(filters.tags?.length || 0);
const filteredVerticals = (meta?.verticals || []).filter(v =>
v.name.toLowerCase().includes(filterQuery.toLowerCase())
);
const filteredSubcategories = (meta?.subcategories || []).filter(s =>
s.name.toLowerCase().includes(filterQuery.toLowerCase())
);
const filteredTags = (meta?.tags || []).filter(t =>
t.name.toLowerCase().includes(filterQuery.toLowerCase())
);
return (
<div className="fixed inset-0 z-50 overflow-hidden">
{/* Backdrop */}
<div
className="absolute inset-0 bg-slate-900/60 backdrop-blur-sm transition-opacity duration-300 animate-fadeIn"
onClick={onClose}
/>
<div className="fixed inset-y-0 right-0 max-w-full flex pl-10">
<div className="w-screen max-w-md bg-white dark:bg-slate-900 shadow-2xl border-l border-slate-200 dark:border-slate-800 flex flex-col transform transition-transform duration-300">
{/* Header */}
<div className="px-6 py-5 border-b border-slate-200 dark:border-slate-800 flex items-center justify-between bg-slate-50/50 dark:bg-slate-900/50">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-slate-100 dark:bg-slate-800 text-slate-900 dark:text-slate-100">
<Filter className="w-5 h-5" />
</div>
<div>
<h2 className="text-lg font-bold text-slate-900 dark:text-slate-100 flex items-center gap-2">
Asset Discovery Filters
{activeCount > 0 && (
<span className="px-2 py-0.5 text-xs font-bold rounded-full bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900">
{activeCount}
</span>
)}
</h2>
<p className="text-xs text-slate-500 dark:text-slate-400">
Narrow catalog by domains, types & tags
</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Quick Search inside Filters */}
<div className="p-4 border-b border-slate-100 dark:border-slate-800 bg-slate-50/30 dark:bg-slate-900/30">
<div className="relative">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input
type="text"
placeholder="Search filter keywords..."
value={filterQuery}
onChange={e => setFilterQuery(e.target.value)}
className="w-full pl-9 pr-4 py-2 text-xs rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400"
/>
</div>
</div>
{/* Body Content */}
<div className="flex-1 overflow-y-auto p-6 space-y-7 custom-scrollbar">
{/* 1. Industry Verticals */}
<div>
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500 mb-3 flex items-center justify-between">
<span>Industry Verticals</span>
<span className="text-[10px] lowercase font-normal text-slate-400">
({filteredVerticals.length})
</span>
</h3>
<div className="space-y-1.5">
{filteredVerticals.map(vertical => {
const isSelected = filters.verticalIds?.includes(vertical.id);
const iconNode = vertical.icon ? VERTICAL_ICONS[vertical.icon] : null;
return (
<button
key={vertical.id}
onClick={() =>
onChangeFilters({
...filters,
verticalIds: toggleArrayItem(filters.verticalIds, vertical.id),
})
}
className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-xs font-medium transition-all ${
isSelected
? 'bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900 font-bold shadow-sm'
: 'text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800/60 border border-transparent'
}`}
>
<div className="flex items-center gap-2.5 min-w-0">
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: vertical.color || '#3b82f6' }}
/>
<span className="shrink-0 opacity-80">
{iconNode}
</span>
<span className="truncate">{vertical.name}</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className={`text-[10px] font-mono px-1.5 py-0.5 rounded ${
isSelected ? 'bg-white/20 text-white dark:bg-slate-800 dark:text-slate-200' : 'bg-slate-100 dark:bg-slate-800 text-slate-500'
}`}>
{vertical._count?.assets ?? 0}
</span>
<div
className={`w-4 h-4 rounded border flex items-center justify-center transition-colors ${
isSelected
? 'bg-amber-500 border-amber-500 text-slate-950'
: 'border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800'
}`}
>
{isSelected && <Check className="w-3 h-3 stroke-[3]" />}
</div>
</div>
</button>
);
})}
</div>
</div>
{/* 2. Content Types */}
<div>
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500 mb-3">
Content Types
</h3>
<div className="grid grid-cols-1 gap-2">
{(meta?.contentTypes || []).map(ct => {
const isSelected = filters.contentTypes?.includes(ct.name);
const config = CONTENT_TYPE_CONFIG[ct.name] || {
label: ct.name,
icon: <FileText className="w-4 h-4" />,
color: 'bg-slate-100 text-slate-700',
};
return (
<button
key={ct.name}
onClick={() =>
onChangeFilters({
...filters,
contentTypes: toggleArrayItem(filters.contentTypes, ct.name),
})
}
className={`flex items-center justify-between p-2.5 rounded-lg border text-xs transition-all ${
isSelected
? 'bg-slate-900 text-white border-slate-900 shadow-sm font-bold dark:bg-slate-100 dark:text-slate-900'
: 'bg-white dark:bg-slate-800/80 text-slate-700 dark:text-slate-200 border-slate-200 dark:border-slate-700 hover:border-slate-400'
}`}
>
<div className="flex items-center gap-2.5">
<span>{config.icon}</span>
<span>{config.label}</span>
</div>
<span
className={`text-[10px] font-mono px-2 py-0.5 rounded-full ${
isSelected
? 'bg-white/20 text-white dark:bg-slate-800 dark:text-slate-200'
: 'bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300'
}`}
>
{ct.count}
</span>
</button>
);
})}
</div>
</div>
{/* 3. Subcategories */}
{filteredSubcategories.length > 0 && (
<div>
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500 mb-3">
Subcategories
</h3>
<div className="flex flex-wrap gap-1.5">
{filteredSubcategories.map(sub => {
const isSelected = filters.subcategories?.includes(sub.name);
return (
<button
key={sub.name}
onClick={() =>
onChangeFilters({
...filters,
subcategories: toggleArrayItem(filters.subcategories, sub.name),
})
}
className={`px-2.5 py-1 rounded-md text-xs font-medium border transition-all ${
isSelected
? 'bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900 border-slate-900 dark:border-slate-100 font-semibold'
: 'bg-slate-50 dark:bg-slate-800/60 text-slate-600 dark:text-slate-300 border-slate-200 dark:border-slate-700 hover:bg-slate-100'
}`}
>
{sub.name} <span className="opacity-60 text-[10px]">({sub.count})</span>
</button>
);
})}
</div>
</div>
)}
{/* 4. Tag Cloud */}
{filteredTags.length > 0 && (
<div>
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500 mb-3">
Tags & Keywords
</h3>
<div className="flex flex-wrap gap-1.5">
{filteredTags.map(tag => {
const isSelected = filters.tags?.includes(tag.name);
return (
<button
key={tag.name}
onClick={() =>
onChangeFilters({
...filters,
tags: toggleArrayItem(filters.tags, tag.name),
})
}
className={`px-2 py-0.5 rounded-full text-xs font-mono transition-all ${
isSelected
? 'bg-slate-900 text-white font-semibold dark:bg-slate-100 dark:text-slate-900'
: 'bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 hover:bg-slate-200 dark:hover:bg-slate-700'
}`}
>
#{tag.name} <span className="opacity-60 text-[10px]">({tag.count})</span>
</button>
);
})}
</div>
</div>
)}
</div>
{/* Footer Actions */}
<div className="p-4 border-t border-slate-200 dark:border-slate-800 bg-slate-50/80 dark:bg-slate-900/80 flex items-center justify-between gap-3">
<button
onClick={onClearAll}
disabled={activeCount === 0}
className="flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<RotateCcw className="w-3.5 h-3.5" />
Clear All ({activeCount})
</button>
<button
onClick={onClose}
className="px-5 py-2 rounded-lg bg-slate-900 hover:bg-slate-800 dark:bg-slate-100 dark:hover:bg-white text-white dark:text-slate-900 text-xs font-bold shadow-md transition-all"
>
Apply Filters
</button>
</div>
</div>
</div>
</div>
);
};

View File

@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react';
import { X, UploadCloud, Eye, FileText, File } from 'lucide-react';
import { uploadAsset, scrapeCaseStudy } from '../../../services/assets-api';
import { uploadAsset, scrapeCaseStudy, getVerticals } from '../../../services/assets-api';
import type { Vertical } from '../../../types/assets';
import Modal from '../../../components/ui/Modal';
import Button from '../../../components/ui/Button';
import { useToast } from '../../../hooks/use-toast';
@ -17,6 +18,9 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
onSuccess
}) => {
const { success, error } = useToast();
const [verticals, setVerticals] = useState<Vertical[]>([]);
const [selectedVerticalId, setSelectedVerticalId] = useState<string>('');
const [uploadTab, setUploadTab] = useState<'file' | 'url' | 'case_study'>('file');
const [uploadFile, setUploadFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
@ -36,6 +40,10 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
const [uploadIsDownloadable, setUploadIsDownloadable] = useState(true);
const [isUploading, setIsUploading] = useState(false);
useEffect(() => {
getVerticals().then(setVerticals).catch(console.error);
}, []);
useEffect(() => {
if (!uploadFile) {
setPreviewUrl(null);
@ -150,6 +158,9 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
formData.append('tags', JSON.stringify(uploadTags.split(',').map(t => t.trim()).filter(Boolean)));
formData.append('githubUrl', uploadGithubUrl);
formData.append('isDownloadable', String(uploadIsDownloadable));
if (selectedVerticalId) {
formData.append('verticalIds', JSON.stringify([selectedVerticalId]));
}
if (thumbnailUrl) {
formData.append('thumbnailUrl', thumbnailUrl);
@ -455,7 +466,20 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Industry Vertical / Domain</label>
<select
value={selectedVerticalId}
onChange={(e) => setSelectedVerticalId(e.target.value)}
className="w-full bg-ink-50 border border-ink-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900"
>
<option value="">General (No Domain)</option>
{verticals.map(v => (
<option key={v.id} value={v.id}>{v.name}</option>
))}
</select>
</div>
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Category</label>
<select

View File

@ -1,7 +1,10 @@
import { useState, useEffect } from "react";
import { motion } from "framer-motion";
import type { Variants } from "framer-motion";
import { UploadCloud, Search, File, Share2, Folder, Sparkles } from "lucide-react";
import {
UploadCloud, Search, File, Share2, Folder, Sparkles, Filter, LayoutGrid,
List, ArrowUpDown, Shield, X
} from "lucide-react";
import { useAuthStore } from "../hooks/use-auth";
import { axiosInstance } from "../services/axios";
import {
@ -13,6 +16,7 @@ import {
rejectDownloadRequest,
downloadAssetFile,
getAssetGroups,
getTaxonomyMeta,
} from "../services/assets-api";
import type { AssetGroup } from "../services/assets-api";
import { useToast } from "../hooks/use-toast";
@ -26,21 +30,25 @@ import { AssetDetailsModal } from "../features/assets/components/AssetDetailsMod
import { AssetViewerModal } from "../features/assets/components/AssetViewerModal";
import { DownloadRequestsModal } from "../features/assets/components/DownloadRequestsModal";
import { ManageGroupsModal } from "../features/assets/components/ManageGroupsModal";
import { FilterDrawer } from "../features/assets/components/FilterDrawer";
import { AssetTableView } from "../features/assets/components/AssetTableView";
import { AssetAdminManagerModal } from "../features/assets/components/AssetAdminManagerModal";
import { PageHeader } from "../components/ui/PageHeader";
import Button from "../components/ui/Button";
import { PageLayout } from "../components/layout/PageLayout";
import Modal from "../components/ui/Modal";
// Type Definitions
import type { Asset, Organization } from "../types/assets";
import type { Asset, Organization, TaxonomyMeta, AssetQueryFilters } from "../types/assets";
const containerVariants: Variants = {
hidden: { opacity: 0 },
show: { opacity: 1, transition: { staggerChildren: 0.05 } },
show: { opacity: 1, transition: { staggerChildren: 0.04 } },
};
const itemVariants: Variants = {
hidden: { opacity: 0, y: 15, scale: 0.98 },
hidden: { opacity: 0, y: 12, scale: 0.98 },
show: {
opacity: 1,
y: 0,
@ -54,25 +62,35 @@ export const AssetsPage = () => {
const user = useAuthStore((state) => state.user);
const [assets, setAssets] = useState<Asset[]>([]);
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [taxonomyMeta, setTaxonomyMeta] = useState<TaxonomyMeta | null>(null);
const [loading, setLoading] = useState(true);
const [groups, setGroups] = useState<AssetGroup[]>([]);
// Search & Filter
// Layout Density State
const [viewMode, setViewMode] = useState<'grid' | 'compact' | 'table'>('grid');
// Search, Sort & Filter State
const [searchQuery, setSearchQuery] = useState("");
const [selectedCategory, setSelectedCategory] = useState<string>("ALL");
const [sortBy, setSortBy] = useState<'newest' | 'oldest' | 'title_asc' | 'title_desc' | 'type'>('newest');
const [filters, setFilters] = useState<AssetQueryFilters>({});
// Find recommended assets based on user's partnerGroup matching any AssetGroup.name (supports comma-separated multiple groups)
// Slide-over Filter Drawer & Admin Manager Controls
const [isFilterDrawerOpen, setIsFilterDrawerOpen] = useState(false);
const [isAdminManagerOpen, setIsAdminManagerOpen] = useState(false);
// Multi-Selection State (Shift+Click Engine)
const [selectedAssetIds, setSelectedAssetIds] = useState<string[]>([]);
const [lastSelectedIndex, setLastSelectedIndex] = useState<number | null>(null);
// Recommended assets logic
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) =>
@ -92,7 +110,6 @@ export const AssetsPage = () => {
const [activeAsset, setActiveAsset] = useState<Asset | null>(null);
const [activeMenuId, setActiveMenuId] = useState<string | null>(null);
const [selectedAssetIds, setSelectedAssetIds] = useState<string[]>([]);
const [expandedAssetId, setExpandedAssetId] = useState<string | null>(null);
const [assetToDelete, setAssetToDelete] = useState<{ id: string, title: string } | null>(null);
@ -100,21 +117,17 @@ export const AssetsPage = () => {
fetchData();
}, []);
useEffect(() => {
if (selectedCategory !== "ALL" && selectedCategory !== "RECOMMENDED") {
setSelectedCategory("ALL");
}
}, [assets, selectedCategory]);
const fetchData = async () => {
setLoading(true);
try {
const assetsData = await getAssets();
setAssets(assetsData);
setSelectedAssetIds([]);
const [assetsData, metaData, groupsData] = await Promise.all([
getAssets({ ...filters, search: searchQuery, sortBy }),
getTaxonomyMeta(),
getAssetGroups(),
]);
// Load groups for both ADMIN (for managing) and PARTNER (for recommendations)
const groupsData = await getAssetGroups();
setAssets(assetsData);
setTaxonomyMeta(metaData);
setGroups(groupsData);
if (user?.role === "ADMIN") {
@ -128,6 +141,53 @@ export const AssetsPage = () => {
}
};
// Re-fetch when query or filters change
useEffect(() => {
const timer = setTimeout(() => {
fetchFilteredAssets();
}, 250);
return () => clearTimeout(timer);
}, [searchQuery, sortBy, filters]);
const fetchFilteredAssets = async () => {
try {
const assetsData = await getAssets({
...filters,
search: searchQuery,
sortBy,
});
setAssets(assetsData);
} catch (err) {
console.error("Failed to filter assets", err);
}
};
// Shift + Click Range Multi-Selection Handler
const handleToggleSelectAsset = (assetId: string, event?: React.MouseEvent) => {
const clickedIndex = filteredAssets.findIndex(a => a.id === assetId);
if (event?.shiftKey && lastSelectedIndex !== null && clickedIndex !== -1) {
const start = Math.min(lastSelectedIndex, clickedIndex);
const end = Math.max(lastSelectedIndex, clickedIndex);
const rangeIds = filteredAssets.slice(start, end + 1).map(a => a.id);
setSelectedAssetIds(prev => Array.from(new Set([...prev, ...rangeIds])));
} else {
setSelectedAssetIds(prev =>
prev.includes(assetId) ? prev.filter(id => id !== assetId) : [...prev, assetId]
);
setLastSelectedIndex(clickedIndex);
}
};
const handleSelectAll = () => {
if (selectedAssetIds.length === filteredAssets.length) {
setSelectedAssetIds([]);
} else {
setSelectedAssetIds(filteredAssets.map(a => a.id));
}
};
const openEditModal = (asset: Asset) => {
setActiveAsset(asset);
setIsEditOpen(true);
@ -139,14 +199,6 @@ export const AssetsPage = () => {
setIsShareOpen(true);
};
const handleToggleSelectAsset = (assetId: string) => {
setSelectedAssetIds((prev) =>
prev.includes(assetId)
? prev.filter((id) => id !== assetId)
: [...prev, assetId]
);
};
const openViewerModal = (asset: Asset) => {
setActiveAsset(asset);
setIsViewerOpen(true);
@ -172,7 +224,6 @@ export const AssetsPage = () => {
setAssetToDelete(null);
await fetchData();
} catch (err: any) {
console.error("Failed to delete asset", err);
error("Failed to delete asset", err.response?.data?.error || "Something went wrong.");
}
};
@ -183,7 +234,6 @@ export const AssetsPage = () => {
success("Download request submitted", "An administrator has been notified of your request.");
await fetchData();
} catch (err: any) {
console.error("Failed to request download access", err);
error("Failed to submit request", err.response?.data?.error || "Something went wrong.");
}
};
@ -194,7 +244,6 @@ export const AssetsPage = () => {
success("Download request approved", "The partner can now download this asset.");
await fetchData();
} catch (err: any) {
console.error("Failed to approve request", err);
error("Failed to approve request", err.response?.data?.error || "Something went wrong.");
}
};
@ -205,7 +254,6 @@ export const AssetsPage = () => {
success("Download request rejected", "The access request was denied.");
await fetchData();
} catch (err: any) {
console.error("Failed to reject request", err);
error("Failed to reject request", err.response?.data?.error || "Something went wrong.");
}
};
@ -214,7 +262,6 @@ export const AssetsPage = () => {
success("Download started", `Downloading "${asset.title}"...`);
try {
await downloadAssetFile(asset.id);
const downloadUrl = asset.url.startsWith("http")
? asset.url
: `${axiosInstance.defaults.baseURL?.replace("/api/v1", "")}${asset.url}`;
@ -227,119 +274,188 @@ export const AssetsPage = () => {
a.click();
document.body.removeChild(a);
setAssets((prev) =>
prev.map((item) =>
item.id === asset.id
? { ...item, downloadsCount: item.downloadsCount + 1 }
: item,
),
setAssets(prev =>
prev.map(item => item.id === asset.id ? { ...item, downloadsCount: item.downloadsCount + 1 } : item)
);
} catch (err: any) {
console.error("Failed to process download", err);
error("Download failed", err.response?.data?.error || "Could not retrieve asset file.");
}
};
// Client-side filtering fallback
const filteredAssets = assets.filter((asset) => {
const query = searchQuery.toLowerCase().trim();
const matchesSearch =
!query ||
asset.title.toLowerCase().includes(query) ||
(asset.description && asset.description.toLowerCase().includes(query)) ||
(asset.categoryId && asset.categoryId.toLowerCase().includes(query)) ||
(asset.subcategory && asset.subcategory.toLowerCase().includes(query)) ||
(asset.githubUrl && asset.githubUrl.toLowerCase().includes(query)) ||
asset.type.toLowerCase().includes(query) ||
asset.tags.some((tag) => tag.toLowerCase().includes(query));
const matchesCategory =
selectedCategory === "ALL" ||
(selectedCategory === "RECOMMENDED" &&
recommendedAssets.some((r) => r.id === asset.id));
return matchesSearch && matchesCategory;
if (selectedCategory === "RECOMMENDED") {
return recommendedAssets.some((r) => r.id === asset.id);
}
return true;
});
const activeFilterCount =
(filters.verticalIds?.length || 0) +
(filters.contentTypes?.length || 0) +
(filters.subcategories?.length || 0) +
(filters.tags?.length || 0);
const pendingRequestsCount = assets.reduce((acc, asset) => {
return (
acc +
(asset.downloadRequests?.filter((r) => r.status === "PENDING").length ||
0)
acc + (asset.downloadRequests?.filter((r) => r.status === "PENDING").length || 0)
);
}, 0);
// Header component
const headerNode = (
<PageHeader
title="Asset Library"
subtitle="Securely manage, distribute, and track marketing collateral and partner resources."
// badge={
// <div className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full bg-ink-100 border border-ink-200 text-[10px] font-bold text-ink-900 tracking-wider uppercase shrink-0">
// <CheckCircle className="w-3.5 h-3.5 text-ink-900" />
// <span>Global CDN Active</span>
// </div>
// }
title="Asset Discovery Platform"
subtitle="Enterprise asset catalog featuring vertical domain search, multi-view density, and partner access controls."
/>
);
// 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="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>
<div className="flex flex-col lg:flex-row gap-3 items-stretch lg:items-center justify-between p-3.5 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm">
<div className="flex flex-wrap items-center gap-3 flex-1 min-w-0">
{/* Search Bar */}
<div className="relative w-full sm:w-[280px] shrink-0">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-ink-50 border border-ink-200 rounded-lg py-1.5 pl-9 pr-4 text-xs font-semibold text-ink-900 placeholder-ink-400 outline-none transition-all focus:border-ink-900/50 focus:ring-2 ring-ink-900/10 hover:border-ink-300 font-sans"
placeholder="Search by title, desc, tag, category..."
className="w-full bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg py-2 pl-9 pr-4 text-xs font-medium text-slate-900 dark:text-slate-100 placeholder-slate-400 outline-none focus:ring-2 ring-slate-400/20 focus:border-slate-500 transition-all"
placeholder="Search catalog titles, tags..."
/>
</div>
{/* Toggle Controls: All Assets vs Recommended */}
{/* Sorting Dropdown */}
<div className="flex items-center gap-1.5 bg-slate-50 dark:bg-slate-800 px-2.5 py-1.5 rounded-lg border border-slate-200 dark:border-slate-700 shrink-0">
<ArrowUpDown className="w-3.5 h-3.5 text-slate-400" />
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as any)}
className="bg-transparent text-xs font-semibold text-slate-700 dark:text-slate-300 outline-none cursor-pointer"
>
<option value="newest">Newest First</option>
<option value="oldest">Oldest First</option>
<option value="title_asc">Title A-Z</option>
<option value="title_desc">Title Z-A</option>
<option value="type">Format / Type</option>
</select>
</div>
{/* View Density Switcher */}
<div className="flex items-center p-0.5 bg-slate-100 dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 shrink-0">
<button
onClick={() => setViewMode('grid')}
className={`p-1.5 rounded-md text-xs transition-all ${
viewMode === 'grid'
? 'bg-white dark:bg-slate-900 text-slate-900 dark:text-white shadow-xs font-bold'
: 'text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
}`}
title="Grid Cards"
>
<LayoutGrid className="w-4 h-4" />
</button>
<button
onClick={() => setViewMode('compact')}
className={`p-1.5 rounded-md text-xs transition-all ${
viewMode === 'compact'
? 'bg-white dark:bg-slate-900 text-slate-900 dark:text-white shadow-xs font-bold'
: 'text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
}`}
title="Compact Cards"
>
<LayoutGrid className="w-3.5 h-3.5 stroke-[2.5]" />
</button>
<button
onClick={() => setViewMode('table')}
className={`p-1.5 rounded-md text-xs transition-all ${
viewMode === 'table'
? 'bg-white dark:bg-slate-900 text-slate-900 dark:text-white shadow-xs font-bold'
: 'text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
}`}
title="Tabular List View (Shift+Click)"
>
<List className="w-4 h-4" />
</button>
</div>
{/* Partner Recommended Toggle */}
{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 items-center gap-1 bg-slate-100 dark:bg-slate-800 p-1 rounded-xl 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"
}`}
className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all ${
selectedCategory === "ALL"
? "bg-slate-900 text-white shadow-xs"
: "text-slate-600 dark:text-slate-300 hover:bg-slate-200"
}`}
>
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"
}`}
className={`px-3 py-1 rounded-lg text-xs font-semibold flex items-center gap-1.5 transition-all ${
selectedCategory === "RECOMMENDED"
? "bg-amber-500 text-slate-950 font-bold shadow-xs"
: "text-amber-600 dark:text-amber-400 hover:bg-amber-500/10"
}`}
>
<Sparkles className="w-3.5 h-3.5 animate-pulse" />
<span>Recommended for You</span>
<Sparkles className="w-3.5 h-3.5" />
<span>Recommended ({recommendedAssets.length})</span>
</button>
</div>
)}
</div>
<div className="flex items-center gap-2 shrink-0 w-full md:w-auto justify-end">
{/* Right Action & Filter Drawer Trigger Area */}
<div className="flex items-center gap-2 shrink-0 justify-end">
{user?.role === "ADMIN" && (
<>
{selectedAssetIds.length > 0 ? (
<Button
onClick={() => setSelectedAssetIds([])}
variant="ghost"
size="sm"
className="text-xs font-semibold text-slate-500 hover:text-slate-900 dark:hover:text-slate-100"
>
Deselect All
</Button>
) : (
<Button
onClick={handleSelectAll}
variant="ghost"
size="sm"
className="text-xs font-semibold text-slate-500 hover:text-slate-900 dark:hover:text-slate-100"
>
Select All
</Button>
)}
<Button
onClick={() => setIsAdminManagerOpen(true)}
variant="secondary"
size="sm"
icon={<Shield className="w-3.5 h-3.5 text-slate-700 dark:text-slate-300" />}
>
Taxonomy & Announcements
</Button>
</>
)}
{user?.role === "ADMIN" && pendingRequestsCount > 0 && (
<Button
onClick={() => setIsRequestsOpen(true)}
variant="secondary"
size="sm"
icon={
<span className="w-4 h-4 rounded-full bg-ink-900 text-ink-0 text-[10px] flex items-center justify-center font-extrabold mr-0.5">
<span className="w-4 h-4 rounded-full bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 text-[10px] flex items-center justify-center font-bold">
{pendingRequestsCount}
</span>
}
>
Download Requests
Requests
</Button>
)}
@ -353,7 +469,7 @@ export const AssetsPage = () => {
size="sm"
icon={<Share2 className="w-3.5 h-3.5" />}
>
Share Selected ({selectedAssetIds.length})
Share ({selectedAssetIds.length})
</Button>
)}
@ -364,7 +480,7 @@ export const AssetsPage = () => {
size="sm"
icon={<Folder className="w-3.5 h-3.5" />}
>
Manage Groups
Groups
</Button>
)}
@ -375,65 +491,110 @@ export const AssetsPage = () => {
size="sm"
icon={<UploadCloud className="w-3.5 h-3.5" />}
>
Create / Upload Asset
Create Asset
</Button>
)}
{/* Filter Drawer Trigger Button positioned on the far right end */}
<button
onClick={() => setIsFilterDrawerOpen(true)}
className={`flex items-center gap-2 px-3.5 py-2 rounded-lg text-xs font-bold border transition-all ${
activeFilterCount > 0
? "bg-slate-900 text-white border-slate-900 shadow-sm dark:bg-slate-100 dark:text-slate-900"
: "bg-slate-50 dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-700 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-700"
}`}
>
<Filter className="w-4 h-4" />
<span>Filters</span>
{activeFilterCount > 0 && (
<span className="px-1.5 py-0.2 text-[10px] font-extrabold rounded-full bg-amber-500 text-slate-950">
{activeFilterCount}
</span>
)}
</button>
</div>
</div>
);
return (
<PageLayout header={headerNode} toolbar={toolbarNode}>
<div className="p-5 flex-1 min-h-0 overflow-y-auto">
<div className="p-5 flex-1 min-h-0 overflow-y-auto custom-scrollbar">
{/* Result Counter & Active Filter Badges */}
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 text-xs">
<div className="text-slate-500 font-medium flex items-center gap-2">
<span>Showing <strong className="text-slate-900 dark:text-slate-100">{filteredAssets.length}</strong> of {taxonomyMeta?.totalAssets || assets.length} assets</span>
{selectedAssetIds.length > 0 && (
<span className="px-2 py-0.5 rounded-md bg-slate-100 dark:bg-slate-800 text-slate-900 dark:text-slate-100 font-bold border border-slate-300 dark:border-slate-700">
{selectedAssetIds.length} selected (Hold Shift to range-select)
</span>
)}
</div>
{activeFilterCount > 0 && (
<button
onClick={() => setFilters({})}
className="text-xs text-slate-700 dark:text-slate-300 hover:text-slate-900 dark:hover:text-white font-bold flex items-center gap-1"
>
<X className="w-3.5 h-3.5" />
Reset Filters ({activeFilterCount})
</button>
)}
</div>
{loading ? (
<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 className="w-8 h-8 border-4 border-slate-400/30 border-t-slate-900 dark:border-t-slate-100 rounded-full animate-spin" />
</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.
<div className="py-16 text-center bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-2xl">
<File className="w-12 h-12 text-slate-300 mx-auto mb-4" />
<h3 className="text-lg font-bold text-slate-900 dark:text-slate-100">No assets match your query</h3>
<p className="text-slate-500 text-xs mt-1 max-w-sm mx-auto">
Try broadening your search keywords or clearing active domain filters.
</p>
<button
onClick={() => { setSearchQuery(''); setFilters({}); }}
className="mt-4 px-4 py-2 rounded-lg bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 text-xs font-bold"
>
Clear All Filters
</button>
</div>
) : viewMode === 'table' ? (
/* TABULAR LIST VIEW */
<AssetTableView
assets={filteredAssets}
selectedIds={selectedAssetIds}
recommendedIds={recommendedAssets.map(r => r.id)}
onToggleSelect={handleToggleSelectAsset}
onSelectAll={handleSelectAll}
onOpenAsset={openViewerModal}
onRequestDownload={(id) => {
const asset = assets.find(a => a.id === id);
if (asset) handleRequestDownload(asset);
}}
userRole={user?.role}
/>
) : (
/* GRID / COMPACT VIEW */
<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"
className={`grid grid-cols-1 ${
viewMode === 'compact'
? 'md:grid-cols-3 xl:grid-cols-5 gap-3'
: '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">
<motion.div
key={asset.id}
variants={itemVariants}
className={`relative ${viewMode === 'compact' ? 'h-[360px]' : 'h-[410px]'} w-full flex flex-col`}
>
<AssetCard
asset={asset}
user={user}
@ -447,7 +608,7 @@ export const AssetsPage = () => {
onDownload={handleDownload}
onRequestDownload={handleRequestDownload}
isSelected={selectedAssetIds.includes(asset.id)}
onToggleSelect={handleToggleSelectAsset}
onToggleSelect={(id, event) => handleToggleSelectAsset(id, event)}
isExpanded={expandedAssetId === asset.id}
onToggleExpand={() => setExpandedAssetId(expandedAssetId === asset.id ? null : asset.id)}
isRecommended={recommendedAssets.some(r => r.id === asset.id)}
@ -460,6 +621,26 @@ export const AssetsPage = () => {
)}
</div>
{/* Slide-over Filter Drawer */}
<FilterDrawer
isOpen={isFilterDrawerOpen}
onClose={() => setIsFilterDrawerOpen(false)}
meta={taxonomyMeta}
filters={filters}
onChangeFilters={setFilters}
onClearAll={() => setFilters({})}
/>
{/* Admin Taxonomy & Announcements Control Modal */}
<AssetAdminManagerModal
isOpen={isAdminManagerOpen}
onClose={() => setIsAdminManagerOpen(false)}
meta={taxonomyMeta}
organizations={organizations}
allAssets={assets}
onRefreshMeta={fetchData}
/>
{/* Modals Container */}
<UploadAssetModal
isOpen={isUploadOpen}
@ -536,8 +717,8 @@ export const AssetsPage = () => {
size="sm"
>
<div className="space-y-4 font-sans">
<p className="text-xs text-ink-600 leading-relaxed">
Are you sure you want to permanently delete <span className="font-bold text-ink-900">"{assetToDelete?.title}"</span>? This action cannot be undone.
<p className="text-xs text-slate-600 dark:text-slate-400 leading-relaxed">
Are you sure you want to permanently delete <span className="font-bold text-slate-900 dark:text-white">"{assetToDelete?.title}"</span>? This action cannot be undone.
</p>
<div className="flex gap-3 justify-end pt-2">
<Button

View File

@ -1,8 +1,16 @@
import { axiosInstance } from './axios';
import type { Asset, Organization } from '../types/assets';
import type { Asset, Organization, Vertical, TaxonomyMeta, AssetQueryFilters } from '../types/assets';
export const getAssets = async (): Promise<Asset[]> => {
const response = await axiosInstance.get<Asset[]>('/assets');
export const getAssets = async (filters?: AssetQueryFilters): Promise<Asset[]> => {
const params: Record<string, string> = {};
if (filters?.search) params.search = filters.search;
if (filters?.verticalIds?.length) params.verticalIds = filters.verticalIds.join(',');
if (filters?.contentTypes?.length) params.contentTypes = filters.contentTypes.join(',');
if (filters?.subcategories?.length) params.subcategories = filters.subcategories.join(',');
if (filters?.tags?.length) params.tags = filters.tags.join(',');
if (filters?.sortBy) params.sortBy = filters.sortBy;
const response = await axiosInstance.get<Asset[]>('/assets', { params });
return response.data;
};
@ -24,6 +32,7 @@ export const updateAsset = async (
description?: string;
categoryId?: string;
subcategory?: string;
verticalIds?: string[];
tags?: string[];
githubUrl?: string;
isDownloadable?: boolean;
@ -104,3 +113,34 @@ export const scrapeCaseStudy = async (url: string): Promise<ScrapedCaseStudy> =>
});
return response.data;
};
// Taxonomy API
export const getVerticals = async (): Promise<Vertical[]> => {
const response = await axiosInstance.get<Vertical[]>('/taxonomy/verticals');
return response.data;
};
export const getTaxonomyMeta = async (): Promise<TaxonomyMeta> => {
const response = await axiosInstance.get<TaxonomyMeta>('/taxonomy/meta');
return response.data;
};
export const createVertical = async (payload: { name: string; icon?: string; description?: string; color?: string }): Promise<Vertical> => {
const response = await axiosInstance.post<Vertical>('/taxonomy/verticals', payload);
return response.data;
};
export const updateVertical = async (id: string, payload: Partial<Vertical>): Promise<Vertical> => {
const response = await axiosInstance.put<Vertical>(`/taxonomy/verticals/${id}`, payload);
return response.data;
};
export const deleteVertical = async (id: string): Promise<void> => {
await axiosInstance.delete(`/taxonomy/verticals/${id}`);
};
// Notification API
export const sendAssetAnnouncement = async (payload: { title: string; message: string; targetOrgIds?: string[]; assetIds?: string[] }): Promise<void> => {
await axiosInstance.post('/assets/notify', payload);
};

View File

@ -26,6 +26,38 @@ export interface DownloadRequest {
};
}
export interface Vertical {
id: string;
name: string;
slug: string;
icon?: string | null;
description?: string | null;
color?: string | null;
orderIndex?: number;
isActive?: boolean;
_count?: {
assets: number;
};
}
export interface TaxonomyMeta {
verticals: Vertical[];
categories: { name: string; count: number }[];
subcategories: { name: string; count: number }[];
contentTypes: { name: string; count: number }[];
tags: { name: string; count: number }[];
totalAssets: number;
}
export interface AssetQueryFilters {
search?: string;
verticalIds?: string[];
contentTypes?: string[];
subcategories?: string[];
tags?: string[];
sortBy?: 'newest' | 'oldest' | 'title_asc' | 'title_desc' | 'type';
}
export interface Asset {
id: string;
title: string;
@ -37,6 +69,7 @@ export interface Asset {
description: string | null;
categoryId: string | null;
subcategory: string | null;
contentType?: string | null;
tags: string[];
downloadsCount: number;
githubUrl: string | null;
@ -46,6 +79,7 @@ export interface Asset {
problemStatement?: string | null;
solution?: string | null;
createdAt: string;
verticals?: Vertical[];
sharedWith?: SharedWithOrg[];
downloadRequests?: DownloadRequest[];
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 283 KiB