Added thumbnail
This commit is contained in:
parent
c298b557c6
commit
673d3d5c13
@ -6,7 +6,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node dist/app.js",
|
"start": "node dist/app.js",
|
||||||
"dev": "nodemon src/app.ts",
|
"dev": "nodemon src/app.ts",
|
||||||
"build": "tsc"
|
"build": "tsc",
|
||||||
|
"test:smtp": "ts-node scripts/test-smtp.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.1083.0",
|
"@aws-sdk/client-s3": "^3.1083.0",
|
||||||
|
|||||||
87
Channel-Backend/scripts/test-smtp.ts
Normal file
87
Channel-Backend/scripts/test-smtp.ts
Normal file
@ -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: '<div style="font-family: sans-serif; padding: 20px; border: 1px solid #ccc;"><h3>SMTP Test Successful!</h3><p>Your backend SMTP configuration is working properly.</p></div>',
|
||||||
|
});
|
||||||
|
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 <your-email-address>');
|
||||||
|
}
|
||||||
|
console.log('==================================================\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('Unexpected error:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@ -23,9 +23,13 @@ export class AssetController {
|
|||||||
|
|
||||||
public uploadAsset = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
public uploadAsset = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
try {
|
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';
|
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');
|
throw new Error('No file uploaded');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -43,15 +47,15 @@ export class AssetController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let fileUrl = '';
|
let fileUrl = '';
|
||||||
if (!isUrlAsset && req.file) {
|
if (!isUrlAsset && assetFile) {
|
||||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
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({
|
await s3Client.send(new PutObjectCommand({
|
||||||
Bucket: BUCKET_NAME,
|
Bucket: BUCKET_NAME,
|
||||||
Key: filename,
|
Key: filename,
|
||||||
Body: req.file.buffer,
|
Body: assetFile.buffer,
|
||||||
ContentType: req.file.mimetype,
|
ContentType: assetFile.mimetype,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
fileUrl = `/uploads/${filename}`;
|
fileUrl = `/uploads/${filename}`;
|
||||||
@ -59,6 +63,25 @@ export class AssetController {
|
|||||||
fileUrl = req.body.url;
|
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) => {
|
const parseJsonOrArray = (val: any) => {
|
||||||
if (!val) return undefined;
|
if (!val) return undefined;
|
||||||
if (typeof val === 'string' && val.trim()) {
|
if (typeof val === 'string' && val.trim()) {
|
||||||
@ -73,9 +96,9 @@ export class AssetController {
|
|||||||
let complianceIds = parseJsonOrArray(req.body.complianceIds);
|
let complianceIds = parseJsonOrArray(req.body.complianceIds);
|
||||||
|
|
||||||
const assetData = {
|
const assetData = {
|
||||||
title: req.body.title || (req.file ? req.file.originalname : 'URL Asset'),
|
title: req.body.title || (assetFile ? assetFile.originalname : 'URL Asset'),
|
||||||
type: req.body.type || (isUrlAsset ? 'url' : req.file!.mimetype),
|
type: req.body.type || (isUrlAsset ? 'url' : assetFile!.mimetype),
|
||||||
size: isUrlAsset ? 0 : req.file!.size,
|
size: isUrlAsset ? 0 : assetFile!.size,
|
||||||
url: fileUrl,
|
url: fileUrl,
|
||||||
uploadedBy: uploaderId,
|
uploadedBy: uploaderId,
|
||||||
description: req.body.description || null,
|
description: req.body.description || null,
|
||||||
@ -85,7 +108,7 @@ export class AssetController {
|
|||||||
githubUrl: req.body.githubUrl || null,
|
githubUrl: req.body.githubUrl || null,
|
||||||
status: req.body.status || 'published',
|
status: req.body.status || 'published',
|
||||||
isDownloadable: req.body.isDownloadable === 'true' || req.body.isDownloadable === true,
|
isDownloadable: req.body.isDownloadable === 'true' || req.body.isDownloadable === true,
|
||||||
thumbnailUrl: req.body.thumbnailUrl || null,
|
thumbnailUrl: thumbnailUrl,
|
||||||
problemStatement: req.body.problemStatement || null,
|
problemStatement: req.body.problemStatement || null,
|
||||||
solution: req.body.solution || null,
|
solution: req.body.solution || null,
|
||||||
verticalIds,
|
verticalIds,
|
||||||
@ -101,6 +124,33 @@ export class AssetController {
|
|||||||
} catch (err) { next(err); }
|
} 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) => {
|
public listAssets = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const userContext = req.user ? { role: req.user.role, userId: req.user.userId } : undefined;
|
const userContext = req.user ? { role: req.user.role, userId: req.user.userId } : undefined;
|
||||||
|
|||||||
@ -8,7 +8,8 @@ const assetController = new AssetController();
|
|||||||
|
|
||||||
router.use(authenticate);
|
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('/scrape-case-study', requireRole('ADMIN'), assetController.scrapeCaseStudy);
|
||||||
router.get('/', assetController.listAssets);
|
router.get('/', assetController.listAssets);
|
||||||
router.patch('/bulk-share', requireRole('ADMIN'), assetController.bulkShareAssets);
|
router.patch('/bulk-share', requireRole('ADMIN'), assetController.bulkShareAssets);
|
||||||
|
|||||||
@ -368,6 +368,22 @@ export class AssetService {
|
|||||||
|
|
||||||
const updateData: any = { ...rest };
|
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) {
|
if (tags !== undefined) {
|
||||||
let parsedTags: string[] = [];
|
let parsedTags: string[] = [];
|
||||||
if (Array.isArray(tags)) {
|
if (Array.isArray(tags)) {
|
||||||
@ -531,16 +547,30 @@ export class AssetService {
|
|||||||
|
|
||||||
public async deleteAsset(id: string) {
|
public async deleteAsset(id: string) {
|
||||||
const asset = await prisma.asset.findUnique({ where: { id } });
|
const asset = await prisma.asset.findUnique({ where: { id } });
|
||||||
if (asset && asset.url.startsWith('/uploads/')) {
|
if (asset) {
|
||||||
const filename = asset.url.replace('/uploads/', '');
|
if (asset.url && asset.url.startsWith('/uploads/')) {
|
||||||
try {
|
const filename = asset.url.replace('/uploads/', '');
|
||||||
await s3Client.send(new DeleteObjectCommand({
|
try {
|
||||||
Bucket: BUCKET_NAME,
|
await s3Client.send(new DeleteObjectCommand({
|
||||||
Key: filename,
|
Bucket: BUCKET_NAME,
|
||||||
}));
|
Key: filename,
|
||||||
console.log(`[S3] Deleted file: ${filename}`);
|
}));
|
||||||
} catch (err) {
|
console.log(`[S3] Deleted asset file: ${filename}`);
|
||||||
console.error(`[S3] Failed to delete file ${filename}:`, err);
|
} 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 } });
|
return await prisma.asset.delete({ where: { id } });
|
||||||
|
|||||||
@ -25,7 +25,7 @@ export class MailService {
|
|||||||
const origin = originStorage.getStore() || process.env.CLIENT_ORIGIN || 'http://localhost:5173';
|
const origin = originStorage.getStore() || process.env.CLIENT_ORIGIN || 'http://localhost:5173';
|
||||||
const inviteUrl = `${origin}/invite?token=${inviteToken}`;
|
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) {
|
if (!isRealEmailAllowed) {
|
||||||
console.log(`[DEV EMAIL SAFEGUARD] Blocked real email dispatch to real user/client: ${email}`);
|
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 }) {
|
public async sendCustomAnnouncement(options: { recipients: string[]; subject: string; messageBody: string }) {
|
||||||
if (!options.recipients || options.recipients.length === 0) return;
|
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) {
|
if (!isRealEmailAllowed) {
|
||||||
console.log(`[DEV EMAIL SAFEGUARD] Blocked announcement email to ${options.recipients.length} recipients (Development mode safety guard). Subject: "${options.subject}"`);
|
console.log(`[DEV EMAIL SAFEGUARD] Blocked announcement email to ${options.recipients.length} recipients (Development mode safety guard). Subject: "${options.subject}"`);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -139,7 +139,8 @@ export const AssetCard: React.FC<AssetCardProps> = ({
|
|||||||
const isPresentation = asset.type.includes('presentation') || asset.url.toLowerCase().endsWith('.pptx') || asset.url.toLowerCase().endsWith('.ppt');
|
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 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 bannerSrc = asset.thumbnailUrl ? asset.thumbnailUrl : asset.url;
|
||||||
|
|
||||||
const handleInspectWithAI = (e: React.MouseEvent) => {
|
const handleInspectWithAI = (e: React.MouseEvent) => {
|
||||||
@ -315,12 +316,10 @@ export const AssetCard: React.FC<AssetCardProps> = ({
|
|||||||
<img
|
<img
|
||||||
src={getFullAssetUrl(bannerSrc)}
|
src={getFullAssetUrl(bannerSrc)}
|
||||||
alt={asset.title}
|
alt={asset.title}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
onError={(e) => {
|
onError={() => setImgError(true)}
|
||||||
e.currentTarget.style.display = 'none';
|
|
||||||
const placeholder = document.getElementById(`fallback-${asset.id}`);
|
|
||||||
if (placeholder) placeholder.style.display = 'flex';
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
) : isPdf ? (
|
) : isPdf ? (
|
||||||
<PdfThumbnail
|
<PdfThumbnail
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { Asset } from '../../../types/assets';
|
import type { Asset } from '../../../types/assets';
|
||||||
|
import { getFullAssetUrl } from '../../../utils/asset-url';
|
||||||
import Modal from '../../../components/ui/Modal';
|
import Modal from '../../../components/ui/Modal';
|
||||||
import Button from '../../../components/ui/Button';
|
import Button from '../../../components/ui/Button';
|
||||||
|
|
||||||
@ -62,7 +63,7 @@ export const AssetDetailsModal: React.FC<AssetDetailsModalProps> = ({
|
|||||||
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans mb-1.5">Banner Image</h4>
|
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans mb-1.5">Banner Image</h4>
|
||||||
<div className="w-full h-44 rounded-lg overflow-hidden border border-ink-200 shadow-sm bg-ink-50">
|
<div className="w-full h-44 rounded-lg overflow-hidden border border-ink-200 shadow-sm bg-ink-50">
|
||||||
<img
|
<img
|
||||||
src={asset.thumbnailUrl}
|
src={getFullAssetUrl(asset.thumbnailUrl)}
|
||||||
alt="Asset Banner"
|
alt="Asset Banner"
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import type { Asset, Category } from '../../../types';
|
import type { Asset, Category } from '../../../types';
|
||||||
import { apiClient } from '../../../lib/api-client';
|
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 { Cpu, Terminal, Brain, Server, Search, Download, Calendar, User, Tag, HelpCircle, X } from 'lucide-react';
|
||||||
import { useToast } from '../../../hooks/use-toast';
|
import { useToast } from '../../../hooks/use-toast';
|
||||||
|
|
||||||
@ -206,7 +207,7 @@ export const AssetExplorer: React.FC = () => {
|
|||||||
{/* Card Image banner */}
|
{/* Card Image banner */}
|
||||||
<div className="h-44 w-full relative overflow-hidden bg-ink-100">
|
<div className="h-44 w-full relative overflow-hidden bg-ink-100">
|
||||||
<img
|
<img
|
||||||
src={asset.thumbnailUrl}
|
src={getFullAssetUrl(asset.thumbnailUrl)}
|
||||||
alt={asset.title}
|
alt={asset.title}
|
||||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||||
/>
|
/>
|
||||||
@ -287,7 +288,7 @@ export const AssetExplorer: React.FC = () => {
|
|||||||
{/* Modal Image banner */}
|
{/* Modal Image banner */}
|
||||||
<div className="h-60 rounded-xl overflow-hidden bg-ink-100 relative">
|
<div className="h-60 rounded-xl overflow-hidden bg-ink-100 relative">
|
||||||
<img
|
<img
|
||||||
src={selectedAsset.thumbnailUrl}
|
src={getFullAssetUrl(selectedAsset.thumbnailUrl)}
|
||||||
alt={selectedAsset.title}
|
alt={selectedAsset.title}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Minimize2, Maximize2, Globe, ExternalLink, FileText, File, Download, Compass, Sparkles } from 'lucide-react';
|
import { Minimize2, Maximize2, Globe, ExternalLink, FileText, File, Download, Compass, Sparkles } from 'lucide-react';
|
||||||
import { axiosInstance } from '../../../services/axios';
|
import { axiosInstance } from '../../../services/axios';
|
||||||
|
import { getFullAssetUrl } from '../../../utils/asset-url';
|
||||||
import type { Asset } from '../../../types/assets';
|
import type { Asset } from '../../../types/assets';
|
||||||
import type { User } from '../../../types/auth';
|
import type { User } from '../../../types/auth';
|
||||||
import Modal from '../../../components/ui/Modal';
|
import Modal from '../../../components/ui/Modal';
|
||||||
@ -811,7 +812,7 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
|
|||||||
<div className="max-w-4xl mx-auto w-full space-y-6">
|
<div className="max-w-4xl mx-auto w-full space-y-6">
|
||||||
{asset.thumbnailUrl && (
|
{asset.thumbnailUrl && (
|
||||||
<div className="w-full h-48 rounded-2xl overflow-hidden border border-ink-200 shadow-sm relative bg-ink-100">
|
<div className="w-full h-48 rounded-2xl overflow-hidden border border-ink-200 shadow-sm relative bg-ink-100">
|
||||||
<img src={asset.thumbnailUrl} alt={asset.title} className="w-full h-full object-cover" />
|
<img src={getFullAssetUrl(asset.thumbnailUrl)} alt={asset.title} className="w-full h-full object-cover" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
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 type { Asset, TaxonomyMeta } from '../../../types/assets';
|
||||||
import Modal from '../../../components/ui/Modal';
|
import Modal from '../../../components/ui/Modal';
|
||||||
import Button from '../../../components/ui/Button';
|
import Button from '../../../components/ui/Button';
|
||||||
@ -33,6 +35,10 @@ export const EditAssetModal: React.FC<EditAssetModalProps> = ({
|
|||||||
const [editTags, setEditTags] = useState('');
|
const [editTags, setEditTags] = useState('');
|
||||||
const [editGithubUrl, setEditGithubUrl] = useState('');
|
const [editGithubUrl, setEditGithubUrl] = useState('');
|
||||||
const [editIsDownloadable, setEditIsDownloadable] = useState(true);
|
const [editIsDownloadable, setEditIsDownloadable] = useState(true);
|
||||||
|
const [editThumbnailMode, setEditThumbnailMode] = useState<'url' | 'file'>('url');
|
||||||
|
const [editThumbnailUrl, setEditThumbnailUrl] = useState('');
|
||||||
|
const [editThumbnailFile, setEditThumbnailFile] = useState<File | null>(null);
|
||||||
|
const [editThumbnailFilePreview, setEditThumbnailFilePreview] = useState<string | null>(null);
|
||||||
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -48,6 +54,9 @@ export const EditAssetModal: React.FC<EditAssetModalProps> = ({
|
|||||||
setEditTags(asset.tags.join(', '));
|
setEditTags(asset.tags.join(', '));
|
||||||
setEditGithubUrl(asset.githubUrl || '');
|
setEditGithubUrl(asset.githubUrl || '');
|
||||||
setEditIsDownloadable(asset.isDownloadable);
|
setEditIsDownloadable(asset.isDownloadable);
|
||||||
|
setEditThumbnailUrl(asset.thumbnailUrl || '');
|
||||||
|
setEditThumbnailFile(null);
|
||||||
|
setEditThumbnailMode('url');
|
||||||
|
|
||||||
setEditVerticalIds(asset.verticals ? asset.verticals.map(v => v.id) : []);
|
setEditVerticalIds(asset.verticals ? asset.verticals.map(v => v.id) : []);
|
||||||
setEditTechStackIds(asset.techStacks ? asset.techStacks.map(t => t.id) : []);
|
setEditTechStackIds(asset.techStacks ? asset.techStacks.map(t => t.id) : []);
|
||||||
@ -56,16 +65,44 @@ export const EditAssetModal: React.FC<EditAssetModalProps> = ({
|
|||||||
}
|
}
|
||||||
}, [asset]);
|
}, [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) => {
|
const toggleSelection = (list: string[], item: string) => {
|
||||||
return list.includes(item) ? list.filter(i => i !== item) : [...list, item];
|
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) => {
|
const handleEditSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!asset) return;
|
if (!asset) return;
|
||||||
|
|
||||||
setIsSavingEdit(true);
|
setIsSavingEdit(true);
|
||||||
try {
|
try {
|
||||||
|
let finalThumbnailUrl: string | null = editThumbnailUrl.trim() || null;
|
||||||
|
|
||||||
|
if (editThumbnailMode === 'file' && editThumbnailFile) {
|
||||||
|
const uploadRes = await uploadThumbnail(editThumbnailFile);
|
||||||
|
finalThumbnailUrl = uploadRes.thumbnailUrl;
|
||||||
|
}
|
||||||
|
|
||||||
await updateAsset(asset.id, {
|
await updateAsset(asset.id, {
|
||||||
title: editTitle,
|
title: editTitle,
|
||||||
description: editDescription,
|
description: editDescription,
|
||||||
@ -78,6 +115,7 @@ export const EditAssetModal: React.FC<EditAssetModalProps> = ({
|
|||||||
tags: editTags.split(',').map(t => t.trim()).filter(Boolean),
|
tags: editTags.split(',').map(t => t.trim()).filter(Boolean),
|
||||||
githubUrl: editGithubUrl,
|
githubUrl: editGithubUrl,
|
||||||
isDownloadable: editIsDownloadable,
|
isDownloadable: editIsDownloadable,
|
||||||
|
thumbnailUrl: finalThumbnailUrl,
|
||||||
});
|
});
|
||||||
success('Changes saved successfully', 'Asset details have been updated.');
|
success('Changes saved successfully', 'Asset details have been updated.');
|
||||||
onSuccess();
|
onSuccess();
|
||||||
@ -131,6 +169,107 @@ export const EditAssetModal: React.FC<EditAssetModalProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<label className="text-xs font-semibold text-ink-500">Thumbnail Banner (Optional)</label>
|
||||||
|
<div className="flex bg-ink-100 p-0.5 rounded-lg border border-ink-200 text-[11px]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setEditThumbnailMode('url'); setEditThumbnailFile(null); }}
|
||||||
|
className={`px-2 py-0.5 font-medium rounded-md transition-all cursor-pointer ${editThumbnailMode === 'url' ? 'bg-ink-0 text-ink-900 shadow-xs border border-ink-200' : 'text-ink-500 hover:text-ink-800'}`}
|
||||||
|
>
|
||||||
|
Image URL
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setEditThumbnailMode('file'); setEditThumbnailUrl(''); }}
|
||||||
|
className={`px-2 py-0.5 font-medium rounded-md transition-all cursor-pointer ${editThumbnailMode === 'file' ? 'bg-ink-0 text-ink-900 shadow-xs border border-ink-200' : 'text-ink-500 hover:text-ink-800'}`}
|
||||||
|
>
|
||||||
|
Upload File
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editThumbnailMode === 'url' ? (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={editThumbnailUrl}
|
||||||
|
onChange={(e) => 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 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditThumbnailUrl('')}
|
||||||
|
className="px-2.5 py-2 rounded-lg bg-red-500/10 hover:bg-red-500/20 text-red-600 border border-red-200 text-xs font-semibold flex items-center gap-1 cursor-pointer"
|
||||||
|
title="Remove Thumbnail"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
<span>Clear</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="border border-dashed border-ink-200 hover:border-ink-400 rounded-xl p-3 text-center transition-colors relative bg-ink-50">
|
||||||
|
{!editThumbnailFile ? (
|
||||||
|
<div className="relative py-2">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={(e) => {
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<UploadCloud className="w-6 h-6 text-ink-400 mx-auto mb-1" />
|
||||||
|
<p className="text-xs font-bold text-ink-900">Click or drag thumbnail image to upload</p>
|
||||||
|
<p className="text-[10px] text-ink-450 mt-0.5">PNG, JPG, WebP, GIF • Recommended size: 480×270px (Max 2MB)</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="relative flex items-center justify-between gap-3 p-1">
|
||||||
|
{editThumbnailFilePreview && (
|
||||||
|
<img src={editThumbnailFilePreview} alt="Thumb Preview" className="w-16 h-12 object-cover rounded-lg border border-ink-200 shadow-xs" />
|
||||||
|
)}
|
||||||
|
<div className="flex-1 text-left min-w-0">
|
||||||
|
<p className="text-xs font-bold text-ink-900 truncate">{editThumbnailFile.name}</p>
|
||||||
|
<p className="text-[10px] text-ink-500">{formatBytes(editThumbnailFile.size)} • Upload ready</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditThumbnailFile(null)}
|
||||||
|
className="p-1 rounded-lg bg-ink-100 hover:bg-red-500/10 text-ink-500 hover:text-red-600 border border-ink-200 transition-colors cursor-pointer"
|
||||||
|
title="Remove thumbnail"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editThumbnailMode === 'url' && editThumbnailUrl && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<label className="text-[11px] font-semibold text-ink-500 mb-1 block">Thumbnail Preview</label>
|
||||||
|
<div className="w-full h-28 border border-ink-200 rounded-lg overflow-hidden relative bg-ink-50">
|
||||||
|
<img
|
||||||
|
src={getFullAssetUrl(editThumbnailUrl)}
|
||||||
|
alt="Thumbnail Preview"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 4-Group Taxonomy Demarcation Selection */}
|
{/* 4-Group Taxonomy Demarcation Selection */}
|
||||||
<div className="space-y-4 pt-3 pb-3 border-t border-b border-slate-200 dark:border-slate-800">
|
<div className="space-y-4 pt-3 pb-3 border-t border-b border-slate-200 dark:border-slate-800">
|
||||||
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400">
|
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400">
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { X, UploadCloud, Eye, FileText, File, Sparkles, Globe, Users } from 'lucide-react';
|
import { X, UploadCloud, Eye, FileText, File, Sparkles, Globe, Users } from 'lucide-react';
|
||||||
import { uploadAsset, scrapeCaseStudy, getTaxonomyMeta, getOrganizations } from '../../../services/assets-api';
|
import { uploadAsset, scrapeCaseStudy, getTaxonomyMeta, getOrganizations } from '../../../services/assets-api';
|
||||||
|
import { getFullAssetUrl } from '../../../utils/asset-url';
|
||||||
import type { TaxonomyMeta, Organization } from '../../../types/assets';
|
import type { TaxonomyMeta, Organization } from '../../../types/assets';
|
||||||
import Modal from '../../../components/ui/Modal';
|
import Modal from '../../../components/ui/Modal';
|
||||||
import Button from '../../../components/ui/Button';
|
import Button from '../../../components/ui/Button';
|
||||||
@ -34,7 +35,10 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
|
|||||||
const [fullImagePreviewUrl, setFullImagePreviewUrl] = useState<string | null>(null);
|
const [fullImagePreviewUrl, setFullImagePreviewUrl] = useState<string | null>(null);
|
||||||
const [uploadUrl, setUploadUrl] = useState('');
|
const [uploadUrl, setUploadUrl] = useState('');
|
||||||
const [caseStudyUrl, setCaseStudyUrl] = useState('');
|
const [caseStudyUrl, setCaseStudyUrl] = useState('');
|
||||||
|
const [thumbnailMode, setThumbnailMode] = useState<'url' | 'file'>('url');
|
||||||
const [thumbnailUrl, setThumbnailUrl] = useState('');
|
const [thumbnailUrl, setThumbnailUrl] = useState('');
|
||||||
|
const [thumbnailFile, setThumbnailFile] = useState<File | null>(null);
|
||||||
|
const [thumbnailFilePreview, setThumbnailFilePreview] = useState<string | null>(null);
|
||||||
const [problemStatement, setProblemStatement] = useState('');
|
const [problemStatement, setProblemStatement] = useState('');
|
||||||
const [solution, setSolution] = useState('');
|
const [solution, setSolution] = useState('');
|
||||||
const [isScraping, setIsScraping] = useState(false);
|
const [isScraping, setIsScraping] = useState(false);
|
||||||
@ -72,6 +76,18 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
|
|||||||
}
|
}
|
||||||
}, [uploadFile]);
|
}, [uploadFile]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!thumbnailFile) {
|
||||||
|
setThumbnailFilePreview(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = URL.createObjectURL(thumbnailFile);
|
||||||
|
setThumbnailFilePreview(url);
|
||||||
|
return () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
}, [thumbnailFile]);
|
||||||
|
|
||||||
const handleFetchCaseStudy = async () => {
|
const handleFetchCaseStudy = async () => {
|
||||||
if (!caseStudyUrl) {
|
if (!caseStudyUrl) {
|
||||||
error('URL Required', 'Please enter a case study URL first.');
|
error('URL Required', 'Please enter a case study URL first.');
|
||||||
@ -180,7 +196,9 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
|
|||||||
formData.append('complianceIds', JSON.stringify(selectedComplianceIds));
|
formData.append('complianceIds', JSON.stringify(selectedComplianceIds));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (thumbnailUrl) {
|
if (thumbnailMode === 'file' && thumbnailFile) {
|
||||||
|
formData.append('thumbnail', thumbnailFile);
|
||||||
|
} else if (thumbnailUrl) {
|
||||||
formData.append('thumbnailUrl', thumbnailUrl);
|
formData.append('thumbnailUrl', thumbnailUrl);
|
||||||
}
|
}
|
||||||
if (uploadTab === 'case_study') {
|
if (uploadTab === 'case_study') {
|
||||||
@ -202,6 +220,9 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
|
|||||||
setUploadUrl('');
|
setUploadUrl('');
|
||||||
setCaseStudyUrl('');
|
setCaseStudyUrl('');
|
||||||
setThumbnailUrl('');
|
setThumbnailUrl('');
|
||||||
|
setThumbnailFile(null);
|
||||||
|
setThumbnailFilePreview(null);
|
||||||
|
setThumbnailMode('url');
|
||||||
setProblemStatement('');
|
setProblemStatement('');
|
||||||
setSolution('');
|
setSolution('');
|
||||||
setUploadTitle('');
|
setUploadTitle('');
|
||||||
@ -466,28 +487,92 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Thumbnail Image URL (Optional)</label>
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
<input
|
<label className="text-xs font-semibold text-ink-500">Thumbnail Banner (Optional)</label>
|
||||||
type="url"
|
<div className="flex bg-ink-100 p-0.5 rounded-lg border border-ink-200 text-[11px]">
|
||||||
value={thumbnailUrl}
|
<button
|
||||||
onChange={(e) => setThumbnailUrl(e.target.value)}
|
type="button"
|
||||||
placeholder="https://example.com/image.png"
|
onClick={() => { setThumbnailMode('url'); setThumbnailFile(null); }}
|
||||||
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"
|
className={`px-2 py-0.5 font-medium rounded-md transition-all cursor-pointer ${thumbnailMode === 'url' ? 'bg-ink-0 text-ink-900 shadow-xs border border-ink-200' : 'text-ink-500 hover:text-ink-800'}`}
|
||||||
/>
|
>
|
||||||
</div>
|
Image URL
|
||||||
|
</button>
|
||||||
{thumbnailUrl && (
|
<button
|
||||||
<div>
|
type="button"
|
||||||
<label className="text-xs font-semibold text-ink-500 mb-1 block">Thumbnail Preview</label>
|
onClick={() => { setThumbnailMode('file'); setThumbnailUrl(''); }}
|
||||||
<div className="w-full h-32 border border-ink-200 rounded-lg overflow-hidden relative bg-ink-50">
|
className={`px-2 py-0.5 font-medium rounded-md transition-all cursor-pointer ${thumbnailMode === 'file' ? 'bg-ink-0 text-ink-900 shadow-xs border border-ink-200' : 'text-ink-500 hover:text-ink-800'}`}
|
||||||
<img
|
>
|
||||||
src={thumbnailUrl}
|
Upload File
|
||||||
alt="Thumbnail Preview"
|
</button>
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
{thumbnailMode === 'url' ? (
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={thumbnailUrl}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="border border-dashed border-ink-200 hover:border-ink-400 rounded-xl p-3 text-center transition-colors relative bg-ink-50">
|
||||||
|
{!thumbnailFile ? (
|
||||||
|
<div className="relative py-2">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={(e) => {
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<UploadCloud className="w-6 h-6 text-ink-400 mx-auto mb-1" />
|
||||||
|
<p className="text-xs font-bold text-ink-900">Click or drag thumbnail image to upload</p>
|
||||||
|
<p className="text-[10px] text-ink-450 mt-0.5">PNG, JPG, WebP, GIF • Recommended size: 480×270px (Max 2MB)</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="relative flex items-center justify-between gap-3 p-1">
|
||||||
|
{thumbnailFilePreview && (
|
||||||
|
<img src={thumbnailFilePreview} alt="Thumb Preview" className="w-16 h-12 object-cover rounded-lg border border-ink-200 shadow-xs" />
|
||||||
|
)}
|
||||||
|
<div className="flex-1 text-left min-w-0">
|
||||||
|
<p className="text-xs font-bold text-ink-900 truncate">{thumbnailFile.name}</p>
|
||||||
|
<p className="text-[10px] text-ink-500">{formatBytes(thumbnailFile.size)} • Upload ready</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setThumbnailFile(null)}
|
||||||
|
className="p-1 rounded-lg bg-ink-100 hover:bg-red-500/10 text-ink-500 hover:text-red-600 border border-ink-200 transition-colors cursor-pointer"
|
||||||
|
title="Remove thumbnail"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{thumbnailMode === 'url' && thumbnailUrl && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<label className="text-[11px] font-semibold text-ink-500 mb-1 block">Thumbnail Preview</label>
|
||||||
|
<div className="w-full h-28 border border-ink-200 rounded-lg overflow-hidden relative bg-ink-50">
|
||||||
|
<img
|
||||||
|
src={getFullAssetUrl(thumbnailUrl)}
|
||||||
|
alt="Thumbnail Preview"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Asset Title</label>
|
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Asset Title</label>
|
||||||
|
|||||||
@ -28,6 +28,15 @@ export const uploadAsset = async (formData: FormData): Promise<void> => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 (
|
export const updateAsset = async (
|
||||||
id: string,
|
id: string,
|
||||||
payload: {
|
payload: {
|
||||||
@ -42,6 +51,7 @@ export const updateAsset = async (
|
|||||||
tags?: string[];
|
tags?: string[];
|
||||||
githubUrl?: string;
|
githubUrl?: string;
|
||||||
isDownloadable?: boolean;
|
isDownloadable?: boolean;
|
||||||
|
thumbnailUrl?: string | null;
|
||||||
shares?: Array<{ organizationId: string; userId: string | null }>;
|
shares?: Array<{ organizationId: string; userId: string | null }>;
|
||||||
}
|
}
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
|
|||||||
22
Channel-Frontend/src/utils/asset-url.ts
Normal file
22
Channel-Frontend/src/utils/asset-url.ts
Normal file
@ -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;
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user