Asset Management & seach, filter funationalities
This commit is contained in:
parent
b2853a952b
commit
61f537057b
@ -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
|
||||
|
||||
148
Channel-Backend/scripts/normalize-data.ts
Normal file
148
Channel-Backend/scripts/normalize-data.ts
Normal 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();
|
||||
});
|
||||
@ -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.' });
|
||||
|
||||
@ -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); }
|
||||
}
|
||||
|
||||
72
Channel-Backend/src/controllers/notification.controller.ts
Normal file
72
Channel-Backend/src/controllers/notification.controller.ts
Normal 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); }
|
||||
};
|
||||
}
|
||||
120
Channel-Backend/src/controllers/taxonomy.controller.ts
Normal file
120
Channel-Backend/src/controllers/taxonomy.controller.ts
Normal 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); }
|
||||
};
|
||||
}
|
||||
11
Channel-Backend/src/routes/notification.routes.ts
Normal file
11
Channel-Backend/src/routes/notification.routes.ts
Normal 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;
|
||||
15
Channel-Backend/src/routes/taxonomy.routes.ts
Normal file
15
Channel-Backend/src/routes/taxonomy.routes.ts
Normal 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;
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
328
Channel-Frontend/src/features/assets/components/FilterDrawer.tsx
Normal file
328
Channel-Frontend/src/features/assets/components/FilterDrawer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
|
||||
@ -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 |
Binary file not shown.
Binary file not shown.
Loading…
Reference in New Issue
Block a user