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 = ({ {asset.title} { - e.currentTarget.style.display = 'none'; - const placeholder = document.getElementById(`fallback-${asset.id}`); - if (placeholder) placeholder.style.display = 'flex'; - }} + onError={() => setImgError(true)} /> ) : isPdf ? ( = ({

Banner Image

Asset Banner 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 */}
{asset.title} @@ -287,7 +288,7 @@ export const AssetExplorer: React.FC = () => { {/* Modal Image banner */}
{selectedAsset.title} 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 && (
- {asset.title} + {asset.title}
)} 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 && ( + Thumb Preview + )} +
+

{editThumbnailFile.name}

+

{formatBytes(editThumbnailFile.size)} • Upload ready

+
+ +
+ )} +
+ )} + + {editThumbnailMode === 'url' && editThumbnailUrl && ( +
+ +
+ Thumbnail Preview +
+
+ )} +
+ {/* 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 && ( -
- -
- Thumbnail Preview +
+ +
+ +
- )} + + {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 && ( + Thumb Preview + )} +
+

{thumbnailFile.name}

+

{formatBytes(thumbnailFile.size)} • Upload ready

+
+ +
+ )} +
+ )} + + {thumbnailMode === 'url' && thumbnailUrl && ( +
+ +
+ Thumbnail Preview +
+
+ )} +
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; +};