diff --git a/Channel-Backend/package.json b/Channel-Backend/package.json
index cdd82ba..5a6318d 100644
--- a/Channel-Backend/package.json
+++ b/Channel-Backend/package.json
@@ -6,7 +6,8 @@
"scripts": {
"start": "node dist/app.js",
"dev": "nodemon src/app.ts",
- "build": "tsc"
+ "build": "tsc",
+ "test:smtp": "ts-node scripts/test-smtp.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1083.0",
diff --git a/Channel-Backend/scripts/test-smtp.ts b/Channel-Backend/scripts/test-smtp.ts
new file mode 100644
index 0000000..4635290
--- /dev/null
+++ b/Channel-Backend/scripts/test-smtp.ts
@@ -0,0 +1,87 @@
+import dotenv from 'dotenv';
+import nodemailer from 'nodemailer';
+import path from 'path';
+
+// Load environment variables from .env
+dotenv.config({ path: path.resolve(__dirname, '../.env') });
+
+async function main() {
+ const host = process.env.SMTP_HOST || 'localhost';
+ const port = parseInt(process.env.SMTP_PORT || '587', 10);
+ const user = process.env.SMTP_USER || '';
+ const pass = process.env.SMTP_PASS || '';
+ const from = process.env.SMTP_FROM || user;
+ const enableReal = process.env.ENABLE_REAL_EMAILS;
+ const nodeEnv = process.env.NODE_ENV;
+
+ console.log('==================================================');
+ console.log(' SMTP CREDENTIALS & SERVICE TEST ');
+ console.log('==================================================');
+ console.log(`• NODE_ENV : ${nodeEnv || '(not set)'}`);
+ console.log(`• ENABLE_REAL_EMAILS : ${enableReal || '(not set)'}`);
+ console.log(`• SMTP_HOST : ${host}`);
+ console.log(`• SMTP_PORT : ${port}`);
+ console.log(`• SMTP_USER : ${user || '(empty)'}`);
+ console.log(`• SMTP_PASS : ${pass ? '********' : '(empty)'}`);
+ console.log(`• SMTP_FROM : ${from}`);
+ console.log('--------------------------------------------------');
+
+ if (enableReal !== 'true') {
+ console.warn('⚠️ WARNING: ENABLE_REAL_EMAILS is not set to "true". Real email dispatch is disabled in application code.');
+ }
+
+ const transporter = nodemailer.createTransport({
+ host,
+ port,
+ secure: port === 465,
+ auth: user && pass ? { user, pass } : undefined,
+ tls: {
+ rejectUnauthorized: false,
+ },
+ });
+
+ console.log('\n[1/2] Verifying SMTP connection & authentication credentials...');
+
+ try {
+ const verified = await transporter.verify();
+ console.log('✅ SUCCESS: SMTP Server is reachable and authentication credentials are VALID!');
+ } catch (err: any) {
+ console.error('❌ FAILED: SMTP Connection or Authentication failed!');
+ console.error(`Reason: ${err.message || err}`);
+ if (err.code === 'ECONNREFUSED') {
+ console.error(`👉 Suggestion: Port ${port} is blocked or not accepting connections on ${host}. Check firewall rules.`);
+ } else if (err.responseCode === 535 || err.code === 'EAUTH') {
+ console.error('👉 Suggestion: Invalid SMTP_USER or SMTP_PASS.');
+ }
+ process.exit(1);
+ }
+
+ // Optional: If an email argument is provided, send a test email
+ const recipient = process.argv[2];
+ if (recipient) {
+ console.log(`\n[2/2] Sending test email to: ${recipient}...`);
+ try {
+ const info = await transporter.sendMail({
+ from: from || user,
+ to: recipient,
+ subject: 'Tech4Biz Channel Partner SMTP Verification',
+ text: 'This is a test email sent from the Tech4Biz SMTP Test Script.',
+ html: '
SMTP Test Successful!
Your backend SMTP configuration is working properly.
',
+ });
+ console.log(`✅ SUCCESS: Email delivered successfully to ${recipient}!`);
+ console.log(`• Message ID: ${info.messageId}`);
+ } catch (err: any) {
+ console.error(`❌ FAILED: Could not send test email to ${recipient}.`);
+ console.error(`Reason: ${err.message || err}`);
+ }
+ } else {
+ console.log('\nℹ️ To send an actual test email, run:');
+ console.log(' npx ts-node scripts/test-smtp.ts ');
+ }
+ console.log('==================================================\n');
+}
+
+main().catch((err) => {
+ console.error('Unexpected error:', err);
+ process.exit(1);
+});
diff --git a/Channel-Backend/src/controllers/asset.controller.ts b/Channel-Backend/src/controllers/asset.controller.ts
index 8ef8317..ec34e65 100644
--- a/Channel-Backend/src/controllers/asset.controller.ts
+++ b/Channel-Backend/src/controllers/asset.controller.ts
@@ -23,9 +23,13 @@ export class AssetController {
public uploadAsset = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
+ const files = req.files as { [fieldname: string]: Express.Multer.File[] } | undefined;
+ const assetFile = files?.file?.[0] || req.file;
+ const thumbnailFile = files?.thumbnail?.[0];
+
const isUrlAsset = req.body.isUrlAsset === 'true' || req.body.isUrlAsset === true || req.body.type === 'url' || req.body.type === 'case_study';
- if (!isUrlAsset && !req.file) {
+ if (!isUrlAsset && !assetFile) {
throw new Error('No file uploaded');
}
@@ -43,15 +47,15 @@ export class AssetController {
}
let fileUrl = '';
- if (!isUrlAsset && req.file) {
+ if (!isUrlAsset && assetFile) {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
- const filename = uniqueSuffix + path.extname(req.file.originalname);
+ const filename = uniqueSuffix + path.extname(assetFile.originalname);
await s3Client.send(new PutObjectCommand({
Bucket: BUCKET_NAME,
Key: filename,
- Body: req.file.buffer,
- ContentType: req.file.mimetype,
+ Body: assetFile.buffer,
+ ContentType: assetFile.mimetype,
}));
fileUrl = `/uploads/${filename}`;
@@ -59,6 +63,25 @@ export class AssetController {
fileUrl = req.body.url;
}
+ // Handle thumbnail file upload if present
+ let thumbnailUrl: string | null = req.body.thumbnailUrl || null;
+ if (thumbnailFile) {
+ if (!thumbnailFile.mimetype.startsWith('image/')) {
+ return res.status(400).json({ error: 'Thumbnail must be an image file' });
+ }
+ const thumbSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
+ const thumbFilename = 'thumb-' + thumbSuffix + path.extname(thumbnailFile.originalname);
+
+ await s3Client.send(new PutObjectCommand({
+ Bucket: BUCKET_NAME,
+ Key: thumbFilename,
+ Body: thumbnailFile.buffer,
+ ContentType: thumbnailFile.mimetype,
+ }));
+
+ thumbnailUrl = `/uploads/${thumbFilename}`;
+ }
+
const parseJsonOrArray = (val: any) => {
if (!val) return undefined;
if (typeof val === 'string' && val.trim()) {
@@ -73,9 +96,9 @@ export class AssetController {
let complianceIds = parseJsonOrArray(req.body.complianceIds);
const assetData = {
- title: req.body.title || (req.file ? req.file.originalname : 'URL Asset'),
- type: req.body.type || (isUrlAsset ? 'url' : req.file!.mimetype),
- size: isUrlAsset ? 0 : req.file!.size,
+ title: req.body.title || (assetFile ? assetFile.originalname : 'URL Asset'),
+ type: req.body.type || (isUrlAsset ? 'url' : assetFile!.mimetype),
+ size: isUrlAsset ? 0 : assetFile!.size,
url: fileUrl,
uploadedBy: uploaderId,
description: req.body.description || null,
@@ -85,7 +108,7 @@ export class AssetController {
githubUrl: req.body.githubUrl || null,
status: req.body.status || 'published',
isDownloadable: req.body.isDownloadable === 'true' || req.body.isDownloadable === true,
- thumbnailUrl: req.body.thumbnailUrl || null,
+ thumbnailUrl: thumbnailUrl,
problemStatement: req.body.problemStatement || null,
solution: req.body.solution || null,
verticalIds,
@@ -101,6 +124,33 @@ export class AssetController {
} catch (err) { next(err); }
}
+ public uploadThumbnail = async (req: AuthRequest, res: Response, next: NextFunction) => {
+ try {
+ const files = req.files as { [fieldname: string]: Express.Multer.File[] } | undefined;
+ const file = req.file || files?.thumbnail?.[0];
+ if (!file) {
+ return res.status(400).json({ error: 'No thumbnail file uploaded' });
+ }
+
+ if (!file.mimetype.startsWith('image/')) {
+ return res.status(400).json({ error: 'Thumbnail must be an image file' });
+ }
+
+ const thumbSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
+ const thumbFilename = 'thumb-' + thumbSuffix + path.extname(file.originalname);
+
+ await s3Client.send(new PutObjectCommand({
+ Bucket: BUCKET_NAME,
+ Key: thumbFilename,
+ Body: file.buffer,
+ ContentType: file.mimetype,
+ }));
+
+ const thumbnailUrl = `/uploads/${thumbFilename}`;
+ res.status(200).json({ thumbnailUrl });
+ } catch (err) { next(err); }
+ }
+
public listAssets = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const userContext = req.user ? { role: req.user.role, userId: req.user.userId } : undefined;
diff --git a/Channel-Backend/src/routes/asset.routes.ts b/Channel-Backend/src/routes/asset.routes.ts
index 2a6d120..aae7d73 100644
--- a/Channel-Backend/src/routes/asset.routes.ts
+++ b/Channel-Backend/src/routes/asset.routes.ts
@@ -8,7 +8,8 @@ const assetController = new AssetController();
router.use(authenticate);
-router.post('/upload', requireRole('ADMIN'), upload.single('file'), assetController.uploadAsset);
+router.post('/upload', requireRole('ADMIN'), upload.fields([{ name: 'file', maxCount: 1 }, { name: 'thumbnail', maxCount: 1 }]), assetController.uploadAsset);
+router.post('/upload-thumbnail', requireRole('ADMIN'), upload.single('thumbnail'), assetController.uploadThumbnail);
router.get('/scrape-case-study', requireRole('ADMIN'), assetController.scrapeCaseStudy);
router.get('/', assetController.listAssets);
router.patch('/bulk-share', requireRole('ADMIN'), assetController.bulkShareAssets);
diff --git a/Channel-Backend/src/services/asset.service.ts b/Channel-Backend/src/services/asset.service.ts
index d5231a6..d79da06 100644
--- a/Channel-Backend/src/services/asset.service.ts
+++ b/Channel-Backend/src/services/asset.service.ts
@@ -368,6 +368,22 @@ export class AssetService {
const updateData: any = { ...rest };
+ if (rest.thumbnailUrl !== undefined) {
+ const existing = await prisma.asset.findUnique({ where: { id }, select: { thumbnailUrl: true } });
+ if (existing?.thumbnailUrl && existing.thumbnailUrl.startsWith('/uploads/') && existing.thumbnailUrl !== rest.thumbnailUrl) {
+ const oldFilename = existing.thumbnailUrl.replace('/uploads/', '');
+ try {
+ await s3Client.send(new DeleteObjectCommand({
+ Bucket: BUCKET_NAME,
+ Key: oldFilename,
+ }));
+ console.log(`[S3] Cleaned up replaced thumbnail file: ${oldFilename}`);
+ } catch (err) {
+ console.error(`[S3] Failed to clean up replaced thumbnail file ${oldFilename}:`, err);
+ }
+ }
+ }
+
if (tags !== undefined) {
let parsedTags: string[] = [];
if (Array.isArray(tags)) {
@@ -531,16 +547,30 @@ export class AssetService {
public async deleteAsset(id: string) {
const asset = await prisma.asset.findUnique({ where: { id } });
- if (asset && asset.url.startsWith('/uploads/')) {
- const filename = asset.url.replace('/uploads/', '');
- try {
- await s3Client.send(new DeleteObjectCommand({
- Bucket: BUCKET_NAME,
- Key: filename,
- }));
- console.log(`[S3] Deleted file: ${filename}`);
- } catch (err) {
- console.error(`[S3] Failed to delete file ${filename}:`, err);
+ if (asset) {
+ if (asset.url && asset.url.startsWith('/uploads/')) {
+ const filename = asset.url.replace('/uploads/', '');
+ try {
+ await s3Client.send(new DeleteObjectCommand({
+ Bucket: BUCKET_NAME,
+ Key: filename,
+ }));
+ console.log(`[S3] Deleted asset file: ${filename}`);
+ } catch (err) {
+ console.error(`[S3] Failed to delete file ${filename}:`, err);
+ }
+ }
+ if (asset.thumbnailUrl && asset.thumbnailUrl.startsWith('/uploads/')) {
+ const thumbFilename = asset.thumbnailUrl.replace('/uploads/', '');
+ try {
+ await s3Client.send(new DeleteObjectCommand({
+ Bucket: BUCKET_NAME,
+ Key: thumbFilename,
+ }));
+ console.log(`[S3] Deleted thumbnail file: ${thumbFilename}`);
+ } catch (err) {
+ console.error(`[S3] Failed to delete thumbnail file ${thumbFilename}:`, err);
+ }
}
}
return await prisma.asset.delete({ where: { id } });
diff --git a/Channel-Backend/src/services/mail.service.ts b/Channel-Backend/src/services/mail.service.ts
index 5b86216..11e0c47 100644
--- a/Channel-Backend/src/services/mail.service.ts
+++ b/Channel-Backend/src/services/mail.service.ts
@@ -25,7 +25,7 @@ export class MailService {
const origin = originStorage.getStore() || process.env.CLIENT_ORIGIN || 'http://localhost:5173';
const inviteUrl = `${origin}/invite?token=${inviteToken}`;
- const isRealEmailAllowed = process.env.ENABLE_REAL_EMAILS === 'true' && process.env.NODE_ENV === 'production';
+ const isRealEmailAllowed = process.env.ENABLE_REAL_EMAILS === 'true';
if (!isRealEmailAllowed) {
console.log(`[DEV EMAIL SAFEGUARD] Blocked real email dispatch to real user/client: ${email}`);
@@ -78,7 +78,7 @@ export class MailService {
public async sendCustomAnnouncement(options: { recipients: string[]; subject: string; messageBody: string }) {
if (!options.recipients || options.recipients.length === 0) return;
- const isRealEmailAllowed = process.env.ENABLE_REAL_EMAILS === 'true' && process.env.NODE_ENV === 'production';
+ const isRealEmailAllowed = process.env.ENABLE_REAL_EMAILS === 'true';
if (!isRealEmailAllowed) {
console.log(`[DEV EMAIL SAFEGUARD] Blocked announcement email to ${options.recipients.length} recipients (Development mode safety guard). Subject: "${options.subject}"`);
return;
diff --git a/Channel-Frontend/src/features/assets/components/AssetCard.tsx b/Channel-Frontend/src/features/assets/components/AssetCard.tsx
index 58ba149..2c695f1 100644
--- a/Channel-Frontend/src/features/assets/components/AssetCard.tsx
+++ b/Channel-Frontend/src/features/assets/components/AssetCard.tsx
@@ -139,7 +139,8 @@ export const AssetCard: React.FC = ({
const isPresentation = asset.type.includes('presentation') || asset.url.toLowerCase().endsWith('.pptx') || asset.url.toLowerCase().endsWith('.ppt');
const isSpreadsheet = asset.type.includes('sheet') || asset.url.toLowerCase().endsWith('.xlsx') || asset.url.toLowerCase().endsWith('.xls') || asset.url.toLowerCase().endsWith('.csv');
- const hasBanner = isImage || !!asset.thumbnailUrl;
+ const [imgError, setImgError] = useState(false);
+ const hasBanner = (isImage || !!asset.thumbnailUrl) && !imgError;
const bannerSrc = asset.thumbnailUrl ? asset.thumbnailUrl : asset.url;
const handleInspectWithAI = (e: React.MouseEvent) => {
@@ -315,12 +316,10 @@ export const AssetCard: React.FC = ({
{
- e.currentTarget.style.display = 'none';
- const placeholder = document.getElementById(`fallback-${asset.id}`);
- if (placeholder) placeholder.style.display = 'flex';
- }}
+ onError={() => setImgError(true)}
/>
) : isPdf ? (
= ({
Banner Image

diff --git a/Channel-Frontend/src/features/assets/components/AssetExplorer.tsx b/Channel-Frontend/src/features/assets/components/AssetExplorer.tsx
index 7738595..b5c75b6 100644
--- a/Channel-Frontend/src/features/assets/components/AssetExplorer.tsx
+++ b/Channel-Frontend/src/features/assets/components/AssetExplorer.tsx
@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react';
import type { Asset, Category } from '../../../types';
import { apiClient } from '../../../lib/api-client';
+import { getFullAssetUrl } from '../../../utils/asset-url';
import { Cpu, Terminal, Brain, Server, Search, Download, Calendar, User, Tag, HelpCircle, X } from 'lucide-react';
import { useToast } from '../../../hooks/use-toast';
@@ -206,7 +207,7 @@ export const AssetExplorer: React.FC = () => {
{/* Card Image banner */}

@@ -287,7 +288,7 @@ export const AssetExplorer: React.FC = () => {
{/* Modal Image banner */}

diff --git a/Channel-Frontend/src/features/assets/components/AssetViewerModal.tsx b/Channel-Frontend/src/features/assets/components/AssetViewerModal.tsx
index 22e57a4..362cc06 100644
--- a/Channel-Frontend/src/features/assets/components/AssetViewerModal.tsx
+++ b/Channel-Frontend/src/features/assets/components/AssetViewerModal.tsx
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Minimize2, Maximize2, Globe, ExternalLink, FileText, File, Download, Compass, Sparkles } from 'lucide-react';
import { axiosInstance } from '../../../services/axios';
+import { getFullAssetUrl } from '../../../utils/asset-url';
import type { Asset } from '../../../types/assets';
import type { User } from '../../../types/auth';
import Modal from '../../../components/ui/Modal';
@@ -811,7 +812,7 @@ export const AssetViewerModal: React.FC
= ({
{asset.thumbnailUrl && (
-

+
)}
diff --git a/Channel-Frontend/src/features/assets/components/EditAssetModal.tsx b/Channel-Frontend/src/features/assets/components/EditAssetModal.tsx
index 9853d7b..f185dbd 100644
--- a/Channel-Frontend/src/features/assets/components/EditAssetModal.tsx
+++ b/Channel-Frontend/src/features/assets/components/EditAssetModal.tsx
@@ -1,5 +1,7 @@
import React, { useState, useEffect } from 'react';
-import { updateAsset, getTaxonomyMeta } from '../../../services/assets-api';
+import { UploadCloud, X, Trash2 } from 'lucide-react';
+import { updateAsset, uploadThumbnail, getTaxonomyMeta } from '../../../services/assets-api';
+import { getFullAssetUrl } from '../../../utils/asset-url';
import type { Asset, TaxonomyMeta } from '../../../types/assets';
import Modal from '../../../components/ui/Modal';
import Button from '../../../components/ui/Button';
@@ -33,6 +35,10 @@ export const EditAssetModal: React.FC
= ({
const [editTags, setEditTags] = useState('');
const [editGithubUrl, setEditGithubUrl] = useState('');
const [editIsDownloadable, setEditIsDownloadable] = useState(true);
+ const [editThumbnailMode, setEditThumbnailMode] = useState<'url' | 'file'>('url');
+ const [editThumbnailUrl, setEditThumbnailUrl] = useState('');
+ const [editThumbnailFile, setEditThumbnailFile] = useState(null);
+ const [editThumbnailFilePreview, setEditThumbnailFilePreview] = useState(null);
const [isSavingEdit, setIsSavingEdit] = useState(false);
useEffect(() => {
@@ -48,6 +54,9 @@ export const EditAssetModal: React.FC = ({
setEditTags(asset.tags.join(', '));
setEditGithubUrl(asset.githubUrl || '');
setEditIsDownloadable(asset.isDownloadable);
+ setEditThumbnailUrl(asset.thumbnailUrl || '');
+ setEditThumbnailFile(null);
+ setEditThumbnailMode('url');
setEditVerticalIds(asset.verticals ? asset.verticals.map(v => v.id) : []);
setEditTechStackIds(asset.techStacks ? asset.techStacks.map(t => t.id) : []);
@@ -56,16 +65,44 @@ export const EditAssetModal: React.FC = ({
}
}, [asset]);
+ useEffect(() => {
+ if (!editThumbnailFile) {
+ setEditThumbnailFilePreview(null);
+ return;
+ }
+ const url = URL.createObjectURL(editThumbnailFile);
+ setEditThumbnailFilePreview(url);
+ return () => {
+ URL.revokeObjectURL(url);
+ };
+ }, [editThumbnailFile]);
+
const toggleSelection = (list: string[], item: string) => {
return list.includes(item) ? list.filter(i => i !== item) : [...list, item];
};
+ const formatBytes = (bytes: number, decimals = 2) => {
+ if (bytes === 0) return '0 Bytes';
+ const k = 1024;
+ const dm = decimals < 0 ? 0 : decimals;
+ const sizes = ['Bytes', 'KB', 'MB', 'GB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
+ };
+
const handleEditSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!asset) return;
setIsSavingEdit(true);
try {
+ let finalThumbnailUrl: string | null = editThumbnailUrl.trim() || null;
+
+ if (editThumbnailMode === 'file' && editThumbnailFile) {
+ const uploadRes = await uploadThumbnail(editThumbnailFile);
+ finalThumbnailUrl = uploadRes.thumbnailUrl;
+ }
+
await updateAsset(asset.id, {
title: editTitle,
description: editDescription,
@@ -78,6 +115,7 @@ export const EditAssetModal: React.FC = ({
tags: editTags.split(',').map(t => t.trim()).filter(Boolean),
githubUrl: editGithubUrl,
isDownloadable: editIsDownloadable,
+ thumbnailUrl: finalThumbnailUrl,
});
success('Changes saved successfully', 'Asset details have been updated.');
onSuccess();
@@ -131,6 +169,107 @@ export const EditAssetModal: React.FC = ({
/>
+
+
+
+
+
+
+
+
+
+ {editThumbnailMode === 'url' ? (
+
+ setEditThumbnailUrl(e.target.value)}
+ placeholder="https://example.com/image.png"
+ className="flex-1 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 placeholder-ink-400"
+ />
+ {editThumbnailUrl && (
+
+ )}
+
+ ) : (
+
+ {!editThumbnailFile ? (
+
+
{
+ const file = e.target.files?.[0];
+ if (file) {
+ if (file.size > 2 * 1024 * 1024) {
+ error('File Too Large', 'Thumbnail image size should be under 2MB.');
+ return;
+ }
+ setEditThumbnailFile(file);
+ }
+ }}
+ className="absolute inset-0 opacity-0 cursor-pointer z-10"
+ />
+
+
Click or drag thumbnail image to upload
+
PNG, JPG, WebP, GIF • Recommended size: 480×270px (Max 2MB)
+
+ ) : (
+
+ {editThumbnailFilePreview && (
+

+ )}
+
+
{editThumbnailFile.name}
+
{formatBytes(editThumbnailFile.size)} • Upload ready
+
+
+
+ )}
+
+ )}
+
+ {editThumbnailMode === 'url' && editThumbnailUrl && (
+
+
+
+
})
+
+
+ )}
+
+
{/* 4-Group Taxonomy Demarcation Selection */}
diff --git a/Channel-Frontend/src/features/assets/components/UploadAssetModal.tsx b/Channel-Frontend/src/features/assets/components/UploadAssetModal.tsx
index 52e84e5..36c2f58 100644
--- a/Channel-Frontend/src/features/assets/components/UploadAssetModal.tsx
+++ b/Channel-Frontend/src/features/assets/components/UploadAssetModal.tsx
@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react';
import { X, UploadCloud, Eye, FileText, File, Sparkles, Globe, Users } from 'lucide-react';
import { uploadAsset, scrapeCaseStudy, getTaxonomyMeta, getOrganizations } from '../../../services/assets-api';
+import { getFullAssetUrl } from '../../../utils/asset-url';
import type { TaxonomyMeta, Organization } from '../../../types/assets';
import Modal from '../../../components/ui/Modal';
import Button from '../../../components/ui/Button';
@@ -34,7 +35,10 @@ export const UploadAssetModal: React.FC = ({
const [fullImagePreviewUrl, setFullImagePreviewUrl] = useState(null);
const [uploadUrl, setUploadUrl] = useState('');
const [caseStudyUrl, setCaseStudyUrl] = useState('');
+ const [thumbnailMode, setThumbnailMode] = useState<'url' | 'file'>('url');
const [thumbnailUrl, setThumbnailUrl] = useState('');
+ const [thumbnailFile, setThumbnailFile] = useState(null);
+ const [thumbnailFilePreview, setThumbnailFilePreview] = useState(null);
const [problemStatement, setProblemStatement] = useState('');
const [solution, setSolution] = useState('');
const [isScraping, setIsScraping] = useState(false);
@@ -72,6 +76,18 @@ export const UploadAssetModal: React.FC = ({
}
}, [uploadFile]);
+ useEffect(() => {
+ if (!thumbnailFile) {
+ setThumbnailFilePreview(null);
+ return;
+ }
+ const url = URL.createObjectURL(thumbnailFile);
+ setThumbnailFilePreview(url);
+ return () => {
+ URL.revokeObjectURL(url);
+ };
+ }, [thumbnailFile]);
+
const handleFetchCaseStudy = async () => {
if (!caseStudyUrl) {
error('URL Required', 'Please enter a case study URL first.');
@@ -180,7 +196,9 @@ export const UploadAssetModal: React.FC = ({
formData.append('complianceIds', JSON.stringify(selectedComplianceIds));
}
- if (thumbnailUrl) {
+ if (thumbnailMode === 'file' && thumbnailFile) {
+ formData.append('thumbnail', thumbnailFile);
+ } else if (thumbnailUrl) {
formData.append('thumbnailUrl', thumbnailUrl);
}
if (uploadTab === 'case_study') {
@@ -202,6 +220,9 @@ export const UploadAssetModal: React.FC = ({
setUploadUrl('');
setCaseStudyUrl('');
setThumbnailUrl('');
+ setThumbnailFile(null);
+ setThumbnailFilePreview(null);
+ setThumbnailMode('url');
setProblemStatement('');
setSolution('');
setUploadTitle('');
@@ -466,28 +487,92 @@ export const UploadAssetModal: React.FC = ({
)}
-
- setThumbnailUrl(e.target.value)}
- placeholder="https://example.com/image.png"
- 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 placeholder-ink-400"
- />
-
-
- {thumbnailUrl && (
-
-
-
-

+
+
+
+
+
- )}
+
+ {thumbnailMode === 'url' ? (
+
setThumbnailUrl(e.target.value)}
+ placeholder="https://example.com/image.png"
+ 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 placeholder-ink-400"
+ />
+ ) : (
+
+ {!thumbnailFile ? (
+
+
{
+ const file = e.target.files?.[0];
+ if (file) {
+ if (file.size > 2 * 1024 * 1024) {
+ error('File Too Large', 'Thumbnail image size should be under 2MB.');
+ return;
+ }
+ setThumbnailFile(file);
+ }
+ }}
+ className="absolute inset-0 opacity-0 cursor-pointer z-10"
+ />
+
+
Click or drag thumbnail image to upload
+
PNG, JPG, WebP, GIF • Recommended size: 480×270px (Max 2MB)
+
+ ) : (
+
+ {thumbnailFilePreview && (
+

+ )}
+
+
{thumbnailFile.name}
+
{formatBytes(thumbnailFile.size)} • Upload ready
+
+
+
+ )}
+
+ )}
+
+ {thumbnailMode === 'url' && thumbnailUrl && (
+
+
+
+
})
+
+
+ )}
+
diff --git a/Channel-Frontend/src/services/assets-api.ts b/Channel-Frontend/src/services/assets-api.ts
index ab6f5e3..a8a91b9 100644
--- a/Channel-Frontend/src/services/assets-api.ts
+++ b/Channel-Frontend/src/services/assets-api.ts
@@ -28,6 +28,15 @@ export const uploadAsset = async (formData: FormData): Promise => {
});
};
+export const uploadThumbnail = async (file: File): Promise<{ thumbnailUrl: string }> => {
+ const formData = new FormData();
+ formData.append('thumbnail', file);
+ const response = await axiosInstance.post<{ thumbnailUrl: string }>('/assets/upload-thumbnail', formData, {
+ headers: { 'Content-Type': 'multipart/form-data' }
+ });
+ return response.data;
+};
+
export const updateAsset = async (
id: string,
payload: {
@@ -42,6 +51,7 @@ export const updateAsset = async (
tags?: string[];
githubUrl?: string;
isDownloadable?: boolean;
+ thumbnailUrl?: string | null;
shares?: Array<{ organizationId: string; userId: string | null }>;
}
): Promise => {
diff --git a/Channel-Frontend/src/utils/asset-url.ts b/Channel-Frontend/src/utils/asset-url.ts
new file mode 100644
index 0000000..3da3a8c
--- /dev/null
+++ b/Channel-Frontend/src/utils/asset-url.ts
@@ -0,0 +1,22 @@
+import { axiosInstance } from '../services/axios';
+
+export const getFullAssetUrl = (url: string | null | undefined): string => {
+ if (!url) return '';
+ if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) {
+ return url;
+ }
+
+ const backendBase = axiosInstance.defaults.baseURL || '/api/v1';
+ const relativeHost = backendBase.replace('/api/v1', '');
+ let resolvedUrl = relativeHost.startsWith('/') || relativeHost === ''
+ ? `${window.location.origin}${relativeHost}${url}`
+ : `${relativeHost}${url}`;
+
+ const currentOrigin = window.location.origin;
+ const isCurrentOriginLocal = currentOrigin.includes('localhost') || currentOrigin.includes('127.0.0.1');
+ if (!isCurrentOriginLocal && (resolvedUrl.includes('localhost') || resolvedUrl.includes('127.0.0.1'))) {
+ resolvedUrl = resolvedUrl.replace(/https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?/, currentOrigin);
+ }
+
+ return resolvedUrl;
+};