Compare commits

..

9 Commits

Author SHA1 Message Date
kenilkb
8bb20a99aa Final_changes 2026-07-10 20:11:26 +05:30
kenilkb
7bd5f9f0e4 mobile_responsiveness_adjustments_tables_grids_and_sidebars 2026-07-10 19:47:46 +05:30
kenilkb
dd4ffff3cc dashboard_remove_client_stats_cards_and_reflow_grid 2026-07-10 19:44:53 +05:30
kenilkb
8ae446eadc typography_increase_heading_subheading_card_font_sizes_by_30_percent 2026-07-10 19:44:35 +05:30
kenilkb
0ef06cf755 fonts_use_self_hosted_fontsource_inter_variable 2026-07-10 19:41:43 +05:30
kenilkb
4efbd49c72 configure_vite_proxy_and_relative_api_urls_for_ngrok_compatibility 2026-07-10 19:05:29 +05:30
kenilkb
741705f90a update_peacock_branding_logo 2026-07-10 19:01:26 +05:30
kenilkb
9281a9d384 implement_bulk_asset_sharing 2026-07-10 18:13:08 +05:30
kenilkb
b7efb22ed1 integrate_minio_seeding_and_docs 2026-07-10 17:47:48 +05:30
40 changed files with 598 additions and 215 deletions

View File

@ -28,7 +28,7 @@ app.use(helmet({
useDefaults: false, useDefaults: false,
directives: { directives: {
"default-src": helmet.contentSecurityPolicy.dangerouslyDisableDefaultSrc, "default-src": helmet.contentSecurityPolicy.dangerouslyDisableDefaultSrc,
"frame-ancestors": ["'self'", "http://localhost:5173", "http://localhost:5000"], "frame-ancestors": ["'self'", "http://localhost:5173", "http://localhost:5000", "https://*.ngrok-free.dev", "https://*.ngrok.io"],
}, },
}, },
frameguard: false, frameguard: false,

View File

@ -88,6 +88,20 @@ export class AssetController {
} catch (err) { next(err); } } catch (err) { next(err); }
} }
public bulkShareAssets = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { assetIds, shares } = req.body;
if (!Array.isArray(assetIds)) {
return res.status(400).json({ error: 'assetIds must be an array' });
}
if (!Array.isArray(shares)) {
return res.status(400).json({ error: 'shares must be an array' });
}
await this.assetService.bulkShareAssets(assetIds, shares);
res.status(200).json({ message: 'Assets shared successfully' });
} catch (err) { next(err); }
}
public shareAsset = async (req: AuthRequest, res: Response, next: NextFunction) => { public shareAsset = async (req: AuthRequest, res: Response, next: NextFunction) => {
try { try {
const { organizationIds } = req.body; const { organizationIds } = req.body;

View File

@ -10,6 +10,7 @@ router.use(authenticate);
router.post('/upload', requireRole('ADMIN'), upload.single('file'), assetController.uploadAsset); router.post('/upload', requireRole('ADMIN'), upload.single('file'), assetController.uploadAsset);
router.get('/', assetController.listAssets); router.get('/', assetController.listAssets);
router.patch('/bulk-share', requireRole('ADMIN'), assetController.bulkShareAssets);
router.get('/:id', assetController.getAsset); router.get('/:id', assetController.getAsset);
router.patch('/:id', requireRole('ADMIN'), assetController.updateAsset); router.patch('/:id', requireRole('ADMIN'), assetController.updateAsset);
router.post('/:id/share', requireRole('ADMIN'), assetController.shareAsset); router.post('/:id/share', requireRole('ADMIN'), assetController.shareAsset);

View File

@ -7,7 +7,7 @@ async function migrateLocalUploads() {
console.log('[Migration] Starting local uploads migration to MinIO...'); console.log('[Migration] Starting local uploads migration to MinIO...');
await ensureBucketExists(); await ensureBucketExists();
const localUploadsDir = path.join(__dirname, '../../uploads'); const localUploadsDir = path.join(__dirname, '../../minio-seed');
if (!fs.existsSync(localUploadsDir)) { if (!fs.existsSync(localUploadsDir)) {
console.log('[Migration] No local uploads directory found.'); console.log('[Migration] No local uploads directory found.');
return; return;

View File

@ -267,6 +267,24 @@ export class AssetService {
return this.getAssetById(assetId); return this.getAssetById(assetId);
} }
public async bulkShareAssets(assetIds: string[], shares: any[]) {
await prisma.$transaction([
prisma.sharedAsset.deleteMany({
where: { assetId: { in: assetIds } }
}),
prisma.sharedAsset.createMany({
data: assetIds.flatMap(assetId =>
shares.map(s => ({
assetId,
organizationId: s.organizationId,
userId: s.userId || null,
}))
),
skipDuplicates: true
})
]);
}
public async incrementDownloadCount(id: string) { public async incrementDownloadCount(id: string) {
return await prisma.asset.update({ return await prisma.asset.update({
where: { id }, where: { id },

View File

@ -5,10 +5,6 @@
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2375BF46' stroke-width='2'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.57-.598-3.75h-.152c-3.196 0-6.1-1.249-8.25-3.286zm0 13.036h.008v.008H12v-.008z' /%3E%3C/svg%3E" /> <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2375BF46' stroke-width='2'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.57-.598-3.75h-.152c-3.196 0-6.1-1.249-8.25-3.286zm0 13.036h.008v.008H12v-.008z' /%3E%3C/svg%3E" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tech4Biz Client & Admin Portal</title> <title>Tech4Biz Client & Admin Portal</title>
<!-- Premium Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -8,6 +8,7 @@
"name": "tech4biz-channel", "name": "tech4biz-channel",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@fontsource-variable/inter": "^5.2.8",
"@hookform/resolvers": "^5.4.0", "@hookform/resolvers": "^5.4.0",
"@tanstack/react-query": "^5.101.2", "@tanstack/react-query": "^5.101.2",
"@tanstack/react-router": "^1.170.17", "@tanstack/react-router": "^1.170.17",
@ -69,6 +70,15 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@fontsource-variable/inter": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.2.8.tgz",
"integrity": "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@hookform/resolvers": { "node_modules/@hookform/resolvers": {
"version": "5.4.0", "version": "5.4.0",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.4.0.tgz", "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.4.0.tgz",

View File

@ -10,6 +10,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/inter": "^5.2.8",
"@hookform/resolvers": "^5.4.0", "@hookform/resolvers": "^5.4.0",
"@tanstack/react-query": "^5.101.2", "@tanstack/react-query": "^5.101.2",
"@tanstack/react-router": "^1.170.17", "@tanstack/react-router": "^1.170.17",

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

View File

@ -63,8 +63,8 @@ export const AdminLayout: React.FC = () => {
className={`h-24 flex items-center border-b border-ink-200 transition-all duration-300 ${isCollapsed ? "justify-center px-4" : "px-8"}`} className={`h-24 flex items-center border-b border-ink-200 transition-all duration-300 ${isCollapsed ? "justify-center px-4" : "px-8"}`}
> >
<Link to="/admin" className="flex items-center gap-3 shrink-0 group"> <Link to="/admin" className="flex items-center gap-3 shrink-0 group">
<div className="w-10 h-10 rounded-xl flex items-center justify-center bg-gradient-to-br from-ink-900 to-ink-800 shadow-lg group-hover:scale-105 transition-all duration-300"> <div className="w-10 h-10 rounded-xl flex items-center justify-center bg-ink-0 shadow-sm border border-ink-200 group-hover:scale-105 transition-all duration-300 p-1">
<ShieldCheck className="w-5 h-5 text-ink-0" /> <img src="/logo.png" alt="Tech4Biz" className="w-full h-full object-contain" />
</div> </div>
{!isCollapsed && ( {!isCollapsed && (
<div className="flex flex-col animate-fade-in"> <div className="flex flex-col animate-fade-in">

View File

@ -42,8 +42,8 @@ export const ClientLayout: React.FC = () => {
{/* Branding */} {/* Branding */}
<div className={`h-24 flex items-center border-b border-ink-200 transition-all duration-300 ${isCollapsed ? 'justify-center px-4' : 'px-8'}`}> <div className={`h-24 flex items-center border-b border-ink-200 transition-all duration-300 ${isCollapsed ? 'justify-center px-4' : 'px-8'}`}>
<Link to="/client" className="flex items-center gap-3 shrink-0 group"> <Link to="/client" className="flex items-center gap-3 shrink-0 group">
<div className="w-10 h-10 rounded-xl flex items-center justify-center bg-gradient-to-br from-ink-900 to-ink-800 shadow-lg group-hover:scale-105 transition-all duration-300"> <div className="w-10 h-10 rounded-xl flex items-center justify-center bg-ink-0 shadow-sm border border-ink-200 group-hover:scale-105 transition-all duration-300 p-1">
<ShieldCheck className="w-5 h-5 text-ink-0" /> <img src="/logo.png" alt="Tech4Biz" className="w-full h-full object-contain" />
</div> </div>
{!isCollapsed && ( {!isCollapsed && (
<div className="flex flex-col animate-fade-in"> <div className="flex flex-col animate-fade-in">

View File

@ -2,7 +2,7 @@ import { Outlet, Link } from '@tanstack/react-router';
import { useAuthStore } from '../../hooks/use-auth'; import { useAuthStore } from '../../hooks/use-auth';
import { useThemeStore } from '../../hooks/use-theme'; import { useThemeStore } from '../../hooks/use-theme';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { LayoutDashboard, FolderKanban, FileSignature, Users, LogOut, Hexagon, Sun, Moon } from 'lucide-react'; import { LayoutDashboard, FolderKanban, FileSignature, Users, LogOut, Sun, Moon } from 'lucide-react';
export const MainLayout = () => { export const MainLayout = () => {
const { isAuthenticated, logout, user } = useAuthStore(); const { isAuthenticated, logout, user } = useAuthStore();
@ -29,8 +29,8 @@ export const MainLayout = () => {
> >
<div className="h-24 flex items-center px-8 border-b border-ink-200"> <div className="h-24 flex items-center px-8 border-b border-ink-200">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="relative flex items-center justify-center w-11 h-11 rounded-2xl bg-gradient-to-tr from-ink-900 to-ink-800 shadow-lg"> <div className="relative flex items-center justify-center w-11 h-11 rounded-2xl bg-ink-0 border border-ink-200 shadow-sm p-1">
<Hexagon className="text-ink-0 w-6 h-6 absolute" /> <img src="/logo.png" alt="Tech4Biz" className="w-full h-full object-contain" />
</div> </div>
<span className="font-extrabold text-xl tracking-tight text-ink-900">Tech4Biz</span> <span className="font-extrabold text-xl tracking-tight text-ink-900">Tech4Biz</span>
</div> </div>

View File

@ -210,7 +210,7 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
}; };
const fileHost = ( const fileHost = (
import.meta.env.VITE_API_URL || "http://localhost:5000/api/v1" import.meta.env.VITE_API_URL || "/api/v1"
).replace("/api/v1", ""); ).replace("/api/v1", "");
const isBothVerified = verifiedDocs.nda && verifiedDocs.msa; const isBothVerified = verifiedDocs.nda && verifiedDocs.msa;
const isCurrentVerified = const isCurrentVerified =

View File

@ -12,13 +12,13 @@ export const PageHeader: React.FC<PageHeaderProps> = ({ title, subtitle, badge,
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-4 border-b border-ink-200"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-4 border-b border-ink-200">
<div className="flex flex-col gap-1 min-w-0"> <div className="flex flex-col gap-1 min-w-0">
<div className="flex items-center gap-2.5 flex-wrap"> <div className="flex items-center gap-2.5 flex-wrap">
<h1 className="text-xl font-bold tracking-tight text-ink-900 truncate"> <h1 className="text-xl page-title font-bold tracking-tight text-ink-900 truncate">
{title} {title}
</h1> </h1>
{badge && <div className="shrink-0">{badge}</div>} {badge && <div className="shrink-0">{badge}</div>}
</div> </div>
{subtitle && ( {subtitle && (
<p className="text-xs font-medium text-ink-500 truncate max-w-3xl"> <p className="text-xs page-subtitle font-medium text-ink-500 truncate max-w-3xl">
{subtitle} {subtitle}
</p> </p>
)} )}

View File

@ -17,6 +17,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
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 { axiosInstance } from '../../../services/axios';
interface AssetCardProps { interface AssetCardProps {
asset: Asset; asset: Asset;
@ -30,6 +31,8 @@ interface AssetCardProps {
onOpenViewer: (asset: Asset) => void; onOpenViewer: (asset: Asset) => void;
onDownload: (asset: Asset) => void; onDownload: (asset: Asset) => void;
onRequestDownload: (asset: Asset) => void; onRequestDownload: (asset: Asset) => void;
isSelected?: boolean;
onToggleSelect?: (assetId: string) => void;
} }
export const AssetCard: React.FC<AssetCardProps> = ({ export const AssetCard: React.FC<AssetCardProps> = ({
@ -43,7 +46,9 @@ export const AssetCard: React.FC<AssetCardProps> = ({
onDelete, onDelete,
onOpenViewer, onOpenViewer,
onDownload, onDownload,
onRequestDownload onRequestDownload,
isSelected = false,
onToggleSelect
}) => { }) => {
const isMenuOpen = activeMenuId === asset.id; const isMenuOpen = activeMenuId === asset.id;
const canDirectDownload = user?.role === 'ADMIN' || asset.isDownloadable || asset.downloadRequests?.[0]?.status === 'APPROVED'; const canDirectDownload = user?.role === 'ADMIN' || asset.isDownloadable || asset.downloadRequests?.[0]?.status === 'APPROVED';
@ -65,42 +70,81 @@ export const AssetCard: React.FC<AssetCardProps> = ({
return File; return File;
}; };
const isRenderable = (type: string) => { const isRenderable = (type: string, url: string) => {
return type === 'url' || type.includes('pdf') || type.includes('image') || type.includes('png') || type.includes('jpg'); const isOffice = type.includes('word') || type.includes('presentation') || type.includes('sheet') ||
url.toLowerCase().endsWith('.docx') || url.toLowerCase().endsWith('.doc') ||
url.toLowerCase().endsWith('.pptx') || url.toLowerCase().endsWith('.ppt') ||
url.toLowerCase().endsWith('.xlsx') || url.toLowerCase().endsWith('.xls');
return type === 'url' || type.includes('pdf') || type.includes('image') || type.includes('png') || type.includes('jpg') || isOffice;
};
const getFullAssetUrl = (url: string) => {
let resolvedUrl = url;
if (!url.startsWith('http')) {
const backendBase = axiosInstance.defaults.baseURL || '/api/v1';
const relativeHost = backendBase.replace('/api/v1', '');
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;
}; };
const Icon = getAssetIcon(asset.type); const Icon = getAssetIcon(asset.type);
const isImage = asset.type.includes('image') || asset.type.includes('png') || asset.type.includes('jpg') || asset.url.match(/\.(png|jpe?g|gif|svg|webp)$/i);
const isPdf = asset.type.includes('pdf') || asset.url.toLowerCase().endsWith('.pdf');
const isWord = asset.type.includes('word') || asset.url.toLowerCase().endsWith('.docx') || asset.url.toLowerCase().endsWith('.doc');
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');
return ( return (
<div className="group relative bg-ink-0 border border-ink-200 rounded-xl p-4 hover:border-ink-300 transition-all duration-300 shadow-sm hover:shadow-md hover:-translate-y-0.5 flex flex-col justify-between"> <div className={`group relative bg-ink-0 border rounded-xl p-4 transition-all duration-300 shadow-sm hover:shadow-md hover:-translate-y-0.5 flex flex-col justify-between ${isSelected ? 'border-ink-900 ring-1 ring-ink-900 bg-ink-50/30' : 'border-ink-200 hover:border-ink-300'}`}>
<div> <div>
<div className="flex justify-between items-start mb-3 relative"> {/* Visual Thumbnail Area */}
<div className="w-10 h-10 rounded-lg flex items-center justify-center text-ink-900 bg-ink-100 border border-ink-200"> <div className="w-full h-36 bg-ink-50 border border-ink-200 rounded-lg mb-3 flex items-center justify-center overflow-hidden relative select-none group-hover:border-ink-300 transition-colors bg-gradient-to-br from-ink-50 to-ink-100/50">
<Icon className="w-5 h-5" /> {/* Absolute overlays for controls */}
<div className="absolute top-2 left-2 z-10 flex items-center gap-1.5">
{user?.role === 'ADMIN' && onToggleSelect && (
<input
type="checkbox"
checked={isSelected}
onChange={() => onToggleSelect(asset.id)}
className="w-3.5 h-3.5 rounded border-ink-300 bg-ink-0 text-ink-900 focus:ring-ink-900/10 cursor-pointer shadow-sm"
/>
)}
</div> </div>
<div className="relative flex items-center gap-1.5"> <div className="absolute top-2 right-2 z-10 flex items-center gap-1">
{isRenderable(asset.type) && ( {isRenderable(asset.type, asset.url) && (
<button <button
onClick={() => onOpenViewer(asset)} onClick={() => onOpenViewer(asset)}
className="p-1 rounded-lg text-ink-500 hover:text-ink-900 hover:bg-ink-100 transition-colors" className="p-1 rounded-md bg-ink-0/90 backdrop-blur text-ink-600 hover:text-ink-900 border border-ink-200 shadow-sm transition-all hover:scale-105"
title="Preview Online" title="Preview Online"
> >
<Eye className="w-4 h-4" /> <Eye className="w-3.5 h-3.5" />
</button> </button>
)} )}
<div className="relative">
<button <button
onClick={() => setActiveMenuId(isMenuOpen ? null : asset.id)} onClick={() => setActiveMenuId(isMenuOpen ? null : asset.id)}
className="p-1 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-100 transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100" className="p-1 rounded-md bg-ink-0/90 backdrop-blur text-ink-600 hover:text-ink-900 border border-ink-200 shadow-sm transition-all hover:scale-105"
> >
<MoreVertical className="w-4 h-4" /> <MoreVertical className="w-3.5 h-3.5" />
</button> </button>
{isMenuOpen && ( {isMenuOpen && (
<> <>
<div className="fixed inset-0 z-10" onClick={() => setActiveMenuId(null)} /> <div className="fixed inset-0 z-10" onClick={() => setActiveMenuId(null)} />
<div className="absolute right-0 mt-8 w-44 bg-ink-0 border border-ink-200 rounded-lg shadow-lg z-20 overflow-hidden py-1"> <div className="absolute right-0 mt-1.5 w-44 bg-ink-0 border border-ink-200 rounded-lg shadow-lg z-20 overflow-hidden py-1">
<button <button
onClick={() => { onClick={() => {
onViewDetails(asset); onViewDetails(asset);
@ -151,6 +195,104 @@ export const AssetCard: React.FC<AssetCardProps> = ({
</div> </div>
</div> </div>
{/* Thumbnail Preview Area Content */}
{isImage ? (
<img
src={getFullAssetUrl(asset.url)}
alt={asset.title}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
onError={(e) => {
e.currentTarget.style.display = 'none';
const placeholder = document.getElementById(`fallback-${asset.id}`);
if (placeholder) placeholder.style.display = 'flex';
}}
/>
) : null}
{/* Fallbacks / simulated thumbnails */}
<div
id={`fallback-${asset.id}`}
className="w-full h-full flex items-center justify-center"
style={{ display: isImage ? 'none' : 'flex' }}
>
{isPdf ? (
<div className="flex flex-col items-center justify-center w-full h-full p-4 relative">
<div className="w-14 h-18 bg-ink-0 border border-ink-200 shadow-sm rounded flex flex-col justify-between p-1.5 relative overflow-hidden">
<div className="absolute top-0 right-0 left-0 bg-red-600 text-ink-0 py-0.5 text-[8px] font-extrabold uppercase text-center tracking-wider">
PDF
</div>
<div className="space-y-1 mt-4">
<div className="h-1 bg-ink-200 rounded w-5/6" />
<div className="h-1 bg-ink-200 rounded w-full" />
<div className="h-1 bg-ink-200 rounded w-4/5" />
</div>
<div className="flex items-center justify-between text-[7px] text-ink-400 font-bold border-t border-ink-100 pt-0.5 mt-1">
<span>PDF</span>
<FileText className="w-2.5 h-2.5 text-red-500" />
</div>
</div>
</div>
) : isPresentation ? (
<div className="flex flex-col items-center justify-center w-full h-full p-4">
<div className="w-18 h-13 bg-amber-500 border border-amber-600 shadow-sm rounded-lg flex flex-col justify-between p-1.5 relative overflow-hidden">
<div className="h-1.5 bg-ink-0/90 rounded w-3/4 mb-1" />
<div className="space-y-1">
<div className="h-1 bg-ink-0/60 rounded w-full" />
<div className="h-1 bg-ink-0/60 rounded w-5/6" />
</div>
<div className="flex items-center justify-between text-[7px] text-ink-0/80 font-bold pt-0.5">
<span>SLIDE</span>
<span className="font-extrabold">PPTX</span>
</div>
</div>
</div>
) : isWord ? (
<div className="flex flex-col items-center justify-center w-full h-full p-4">
<div className="w-14 h-18 bg-ink-0 border border-ink-200 shadow-sm rounded flex flex-col justify-between p-1.5 relative overflow-hidden">
<div className="absolute top-0 right-0 left-0 bg-blue-600 text-ink-0 py-0.5 text-[8px] font-extrabold uppercase text-center tracking-wider">
DOC
</div>
<div className="space-y-1 mt-4">
<div className="h-1 bg-ink-200 rounded w-full" />
<div className="h-1 bg-ink-200 rounded w-5/6" />
<div className="h-1 bg-ink-200 rounded w-full" />
</div>
<div className="flex items-center justify-between text-[7px] text-ink-400 font-bold border-t border-ink-100 pt-0.5 mt-1">
<span>WORD</span>
<FileText className="w-2.5 h-2.5 text-blue-500" />
</div>
</div>
</div>
) : isSpreadsheet ? (
<div className="flex flex-col items-center justify-center w-full h-full p-4">
<div className="w-16 h-13 bg-emerald-600 border border-emerald-700 shadow-sm rounded-lg flex flex-col justify-between p-1.5 relative overflow-hidden">
<div className="grid grid-cols-3 gap-0.5 mt-0.5">
<div className="h-1.5 bg-ink-0/90 rounded-sm" />
<div className="h-1.5 bg-ink-0/70 rounded-sm" />
<div className="h-1.5 bg-ink-0/70 rounded-sm" />
<div className="h-1.5 bg-ink-0/60 rounded-sm" />
<div className="h-1.5 bg-ink-0/80 rounded-sm" />
<div className="h-1.5 bg-ink-0/60 rounded-sm" />
</div>
<div className="flex items-center justify-between text-[7px] text-emerald-100 font-bold pt-0.5">
<span>SHEET</span>
<span className="font-extrabold">XLSX</span>
</div>
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center w-full h-full p-4">
<div className="w-12 h-12 rounded-xl bg-ink-100 border border-ink-200 flex items-center justify-center shadow-inner relative group-hover:scale-105 transition-transform">
<Icon className="w-6 h-6 text-ink-650" />
<div className="absolute -bottom-1 -right-1 bg-ink-800 border border-ink-700 rounded px-1 py-0.5 text-[7px] font-bold text-ink-0 uppercase">
{asset.type === 'url' ? 'LINK' : 'FILE'}
</div>
</div>
</div>
)}
</div>
</div>
<div className="space-y-1 mb-4"> <div className="space-y-1 mb-4">
<div className="flex items-center gap-1.5 mb-2"> <div className="flex items-center gap-1.5 mb-2">
<div className="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold text-ink-500 uppercase tracking-wider bg-ink-50 border border-ink-200"> <div className="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold text-ink-500 uppercase tracking-wider bg-ink-50 border border-ink-200">
@ -164,7 +306,7 @@ export const AssetCard: React.FC<AssetCardProps> = ({
)} )}
</div> </div>
<h3 className="font-bold text-ink-900 text-sm leading-snug line-clamp-2 group-hover:text-ink-950 transition-colors" title={asset.title}> <h3 className="font-bold text-ink-900 text-sm asset-card-title leading-snug line-clamp-2 group-hover:text-ink-950 transition-colors" title={asset.title}>
{asset.title} {asset.title}
</h3> </h3>
<p className="text-[11px] font-medium text-ink-400 mt-1"> <p className="text-[11px] font-medium text-ink-400 mt-1">
@ -227,3 +369,4 @@ export const AssetCard: React.FC<AssetCardProps> = ({
</div> </div>
); );
}; };

View File

@ -101,8 +101,8 @@ export const AssetExplorer: React.FC = () => {
<div className="space-y-6 text-ink-900"> <div className="space-y-6 text-ink-900">
<div className="rounded-2xl bg-ink-0 p-6 md:p-8 border border-ink-200 flex flex-col md:flex-row items-center justify-between gap-6"> <div className="rounded-2xl bg-ink-0 p-6 md:p-8 border border-ink-200 flex flex-col md:flex-row items-center justify-between gap-6">
<div className="space-y-2"> <div className="space-y-2">
<h1 className="text-2xl font-bold text-ink-900 md:text-3xl">Tech4Biz Asset Explorer</h1> <h1 className="text-2xl explorer-title font-bold text-ink-900 md:text-3xl">Tech4Biz Asset Explorer</h1>
<p className="text-sm text-ink-700 max-w-xl"> <p className="text-sm explorer-desc text-ink-700 max-w-xl">
Browse, preview, and download proprietary hardware IP, framework assemblies, and cloud-native building blocks licensed under your master agreements. Browse, preview, and download proprietary hardware IP, framework assemblies, and cloud-native building blocks licensed under your master agreements.
</p> </p>
</div> </div>

View File

@ -152,8 +152,8 @@ export const AssetManagement: React.FC = () => {
<thead> <thead>
<tr className="border-b border-ink-100 bg-ink-50 text-xs font-bold uppercase tracking-wider text-ink-600"> <tr className="border-b border-ink-100 bg-ink-50 text-xs font-bold uppercase tracking-wider text-ink-600">
<th className="px-5 py-3.5">Asset Title / Category</th> <th className="px-5 py-3.5">Asset Title / Category</th>
<th className="px-5 py-3.5">Tags</th> <th className="px-5 py-3.5 hidden md:table-cell">Tags</th>
<th className="px-5 py-3.5">Downloads</th> <th className="px-5 py-3.5 hidden sm:table-cell">Downloads</th>
<th className="px-5 py-3.5">Status</th> <th className="px-5 py-3.5">Status</th>
<th className="px-5 py-3.5 text-right">Actions</th> <th className="px-5 py-3.5 text-right">Actions</th>
</tr> </tr>
@ -184,7 +184,7 @@ export const AssetManagement: React.FC = () => {
</div> </div>
</div> </div>
</td> </td>
<td className="px-5 py-4"> <td className="px-5 py-4 hidden md:table-cell">
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{asset.tags.map(t => ( {asset.tags.map(t => (
<span key={t} className="rounded bg-ink-50 px-2 py-0.5 text-[10px] font-semibold text-ink-600 border border-ink-100"> <span key={t} className="rounded bg-ink-50 px-2 py-0.5 text-[10px] font-semibold text-ink-600 border border-ink-100">
@ -193,7 +193,7 @@ export const AssetManagement: React.FC = () => {
))} ))}
</div> </div>
</td> </td>
<td className="px-5 py-4 font-mono font-semibold text-ink-700"> <td className="px-5 py-4 font-mono font-semibold text-ink-700 hidden sm:table-cell">
{asset.downloadsCount} {asset.downloadsCount}
</td> </td>
<td className="px-5 py-4"> <td className="px-5 py-4">

View File

@ -26,14 +26,30 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
const [isLoadingText, setIsLoadingText] = useState(false); const [isLoadingText, setIsLoadingText] = useState(false);
const [textPreviewContent, setTextPreviewContent] = useState(''); const [textPreviewContent, setTextPreviewContent] = useState('');
const isLocalUrl = (url: string) => { const getFullAssetUrl = (url: string) => {
return url.includes('localhost') || url.includes('127.0.0.1'); let resolvedUrl = url;
if (!url.startsWith('http')) {
const backendBase = axiosInstance.defaults.baseURL || '/api/v1';
const relativeHost = backendBase.replace('/api/v1', '');
resolvedUrl = relativeHost.startsWith('/') || relativeHost === ''
? `${window.location.origin}${relativeHost}${url}`
: `${relativeHost}${url}`;
}
// If current origin is a public domain (like ngrok) but the resolved URL is local,
// rewrite the local URL host to match the public origin so that external viewers can fetch it.
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;
}; };
const getFullAssetUrl = (url: string) => { const isLocalUrl = (url: string) => {
return url.startsWith('http') const resolved = getFullAssetUrl(url);
? url return resolved.includes('localhost') || resolved.includes('127.0.0.1');
: `${axiosInstance.defaults.baseURL?.replace('/api/v1', '')}${url}`;
}; };
const getGithubRawUrl = (url: string) => { const getGithubRawUrl = (url: string) => {

View File

@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { updateAsset } from '../../../services/assets-api'; import { updateAsset, bulkShareAssets } from '../../../services/assets-api';
import type { Asset, Organization, ShareItem } from '../../../types/assets'; import type { Asset, Organization, ShareItem } 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';
@ -11,6 +11,7 @@ interface ShareAssetModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
asset: Asset | null; asset: Asset | null;
assetIds?: string[] | null;
organizations: Organization[]; organizations: Organization[];
onSuccess: () => void; onSuccess: () => void;
} }
@ -19,6 +20,7 @@ export const ShareAssetModal: React.FC<ShareAssetModalProps> = ({
isOpen, isOpen,
onClose, onClose,
asset, asset,
assetIds,
organizations, organizations,
onSuccess onSuccess
}) => { }) => {
@ -35,8 +37,10 @@ export const ShareAssetModal: React.FC<ShareAssetModalProps> = ({
userId: s.userId userId: s.userId
})) || [] })) || []
); );
} else {
setSharesList([]);
} }
}, [asset]); }, [asset, isOpen]);
const isOrgSharedEntirely = (orgId: string) => { const isOrgSharedEntirely = (orgId: string) => {
return sharesList.some(s => s.organizationId === orgId && s.userId === null); return sharesList.some(s => s.organizationId === orgId && s.userId === null);
@ -72,13 +76,17 @@ export const ShareAssetModal: React.FC<ShareAssetModalProps> = ({
const handleShareSubmit = async (e: React.FormEvent) => { const handleShareSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!asset) return; if (!asset && (!assetIds || assetIds.length === 0)) return;
setIsSavingShare(true); setIsSavingShare(true);
try { try {
if (asset) {
await updateAsset(asset.id, { await updateAsset(asset.id, {
shares: sharesList shares: sharesList
}); });
} else if (assetIds && assetIds.length > 0) {
await bulkShareAssets(assetIds, sharesList);
}
success('Share permissions updated', 'The asset visibility settings have been updated.'); success('Share permissions updated', 'The asset visibility settings have been updated.');
onSuccess(); onSuccess();
onClose(); onClose();
@ -92,10 +100,10 @@ export const ShareAssetModal: React.FC<ShareAssetModalProps> = ({
return ( return (
<Modal <Modal
isOpen={isOpen && !!asset} isOpen={isOpen && (!!asset || (!!assetIds && assetIds.length > 0))}
onClose={onClose} onClose={onClose}
title="Share Settings" title="Share Settings"
subtitle={asset?.title} subtitle={asset ? asset.title : `${assetIds?.length || 0} selected assets`}
size="md" size="md"
footer={ footer={
<> <>
@ -119,7 +127,7 @@ export const ShareAssetModal: React.FC<ShareAssetModalProps> = ({
</> </>
} }
> >
{asset && ( {(asset || (assetIds && assetIds.length > 0)) && (
<form id="share-asset-form" onSubmit={handleShareSubmit} className="space-y-4"> <form id="share-asset-form" onSubmit={handleShareSubmit} className="space-y-4">
<p className="text-xs text-ink-600 leading-relaxed font-sans"> <p className="text-xs text-ink-600 leading-relaxed font-sans">
Select organizations or expand to specify exact users that can access this asset: Select organizations or expand to specify exact users that can access this asset:

View File

@ -95,14 +95,12 @@ const OrbitingRing: React.FC = () => (
{/* Center icon */} {/* Center icon */}
<div className="absolute inset-0 flex items-center justify-center z-10"> <div className="absolute inset-0 flex items-center justify-center z-10">
<div <div
className="w-16 h-16 rounded-2xl flex items-center justify-center animate-pulse-glow" className="w-16 h-16 rounded-2xl flex items-center justify-center animate-pulse-glow bg-white border border-ink-200 shadow-sm p-2"
style={{ style={{
background: 'linear-gradient(135deg, var(--color-primary-400), var(--color-primary-600))',
boxShadow: '0 0 30px rgba(162,231,113,0.5)',
transform: 'perspective(300px) rotateY(-8deg) rotateX(4deg)', transform: 'perspective(300px) rotateY(-8deg) rotateX(4deg)',
}} }}
> >
<ShieldCheck className="w-8 h-8" style={{ color: 'var(--color-ink-800)' }} /> <img src="/logo.png" alt="Tech4Biz" className="w-full h-full object-contain" />
</div> </div>
</div> </div>
@ -260,10 +258,10 @@ export const LoginForm: React.FC = () => {
<OrbitingRing /> <OrbitingRing />
<div> <div>
<h1 className="text-2xl font-extrabold tracking-tight" style={{ color: 'var(--color-ink-800)' }}> <h1 className="text-2xl login-form-title font-extrabold tracking-tight" style={{ color: 'var(--color-ink-800)' }}>
{mfaPendingEmail ? 'Verify Identity' : 'Tech4Biz Portal'} {mfaPendingEmail ? 'Verify Identity' : 'Tech4Biz Portal'}
</h1> </h1>
<p className="text-sm mt-1.5" style={{ color: 'var(--color-ink-600)' }}> <p className="text-sm login-form-subtitle mt-1.5" style={{ color: 'var(--color-ink-600)' }}>
{mfaPendingEmail {mfaPendingEmail
? `Code sent to ${mfaPendingEmail}` ? `Code sent to ${mfaPendingEmail}`
: 'Enterprise hardware & software asset distribution'} : 'Enterprise hardware & software asset distribution'}

1
Channel-Frontend/src/fontsource.d.ts vendored Normal file
View File

@ -0,0 +1 @@
declare module '@fontsource-variable/inter';

View File

@ -40,7 +40,7 @@
--color-info: #3b82f6; --color-info: #3b82f6;
/* ── Typography ── */ /* ── Typography ── */
--font-sans: 'Outfit', 'Inter', ui-sans-serif, system-ui, sans-serif; --font-sans: 'Inter Variable', 'Inter', ui-sans-serif, system-ui, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace; --font-mono: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
/* ── Spacing Scale ── */ /* ── Spacing Scale ── */
@ -594,3 +594,71 @@ body {
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent); background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
animation: shimmer 1.5s linear infinite; animation: shimmer 1.5s linear infinite;
} }
/* ============================================================
TYPOGRAPHY FINE-TUNING (+30% size increase)
============================================================ */
.page-title {
/* text-xl fallback: 1.25rem (20px) * 1.3 = 1.625rem (26px) */
font-size: 1.625rem !important;
}
.page-subtitle {
/* text-xs fallback: 0.75rem (12px) * 1.3 = 0.975rem (15.6px) */
font-size: 0.975rem !important;
}
.action-card-title {
/* text-lg fallback: 1.125rem (18px) * 1.3 = 1.4625rem (23.4px) */
font-size: 1.4625rem !important;
}
.action-card-desc {
/* text-xs fallback: 0.75rem (12px) * 1.3 = 0.975rem (15.6px) */
font-size: 0.975rem !important;
}
.asset-card-title {
/* text-sm fallback: 0.875rem (14px) * 1.3 = 1.1375rem (18.2px) */
font-size: 1.1375rem !important;
}
.explorer-title {
/* text-2xl fallback: 1.5rem (24px) * 1.3 = 1.95rem (31.2px) */
font-size: 1.95rem !important;
}
@media (min-width: 768px) {
.explorer-title {
/* md:text-3xl fallback: 1.875rem (30px) * 1.3 = 2.4375rem (39px) */
font-size: 2.4375rem !important;
}
}
.explorer-desc {
/* text-sm fallback: 0.875rem (14px) * 1.3 = 1.1375rem (18.2px) */
font-size: 1.1375rem !important;
}
.agreement-card-title {
/* text-base fallback: 1.0rem (16px) * 1.3 = 1.3rem (20.8px) */
font-size: 1.3rem !important;
}
.agreement-card-desc {
/* text-xs fallback: 0.75rem (12px) * 1.3 = 0.975rem (15.6px) */
font-size: 0.975rem !important;
}
.login-title {
/* text-3xl fallback: 1.875rem (30px) * 1.3 = 2.4375rem (39px) */
font-size: 2.4375rem !important;
}
@media (min-width: 1024px) {
.login-title {
/* lg:text-4xl fallback: 2.25rem (36px) * 1.3 = 2.925rem (46.8px) */
font-size: 2.925rem !important;
}
}
.login-desc {
/* text-sm fallback: 0.875rem (14px) * 1.3 = 1.1375rem (18.2px) */
font-size: 1.1375rem !important;
}
.login-form-title {
/* text-2xl fallback: 1.5rem (24px) * 1.3 = 1.95rem (31.2px) */
font-size: 1.95rem !important;
}
.login-form-subtitle {
/* text-sm fallback: 0.875rem (14px) * 1.3 = 1.1375rem (18.2px) */
font-size: 1.1375rem !important;
}

View File

@ -1,3 +1,4 @@
import '@fontsource-variable/inter'
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import './index.css' import './index.css'

View File

@ -1,7 +1,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import type { Variants } from "framer-motion"; import type { Variants } from "framer-motion";
import { UploadCloud, Search, File, CheckCircle } from "lucide-react"; import { UploadCloud, Search, File, CheckCircle, Share2 } from "lucide-react";
import { useAuthStore } from "../hooks/use-auth"; import { useAuthStore } from "../hooks/use-auth";
import { axiosInstance } from "../services/axios"; import { axiosInstance } from "../services/axios";
import { import {
@ -66,6 +66,7 @@ export const AssetsPage = () => {
const [activeAsset, setActiveAsset] = useState<Asset | null>(null); const [activeAsset, setActiveAsset] = useState<Asset | null>(null);
const [activeMenuId, setActiveMenuId] = useState<string | null>(null); const [activeMenuId, setActiveMenuId] = useState<string | null>(null);
const [selectedAssetIds, setSelectedAssetIds] = useState<string[]>([]);
useEffect(() => { useEffect(() => {
fetchData(); fetchData();
@ -76,6 +77,7 @@ export const AssetsPage = () => {
try { try {
const assetsData = await getAssets(); const assetsData = await getAssets();
setAssets(assetsData); setAssets(assetsData);
setSelectedAssetIds([]);
if (user?.role === "ADMIN") { if (user?.role === "ADMIN") {
const orgsData = await getOrganizations(); const orgsData = await getOrganizations();
@ -94,10 +96,19 @@ export const AssetsPage = () => {
}; };
const openShareModal = (asset: Asset) => { const openShareModal = (asset: Asset) => {
setSelectedAssetIds([]);
setActiveAsset(asset); setActiveAsset(asset);
setIsShareOpen(true); setIsShareOpen(true);
}; };
const handleToggleSelectAsset = (assetId: string) => {
setSelectedAssetIds((prev) =>
prev.includes(assetId)
? prev.filter((id) => id !== assetId)
: [...prev, assetId]
);
};
const openViewerModal = (asset: Asset) => { const openViewerModal = (asset: Asset) => {
setActiveAsset(asset); setActiveAsset(asset);
setIsViewerOpen(true); setIsViewerOpen(true);
@ -288,6 +299,20 @@ export const AssetsPage = () => {
</Button> </Button>
)} )}
{user?.role === "ADMIN" && selectedAssetIds.length > 0 && (
<Button
onClick={() => {
setActiveAsset(null);
setIsShareOpen(true);
}}
variant="secondary"
size="sm"
icon={<Share2 className="w-3.5 h-3.5" />}
>
Share Selected ({selectedAssetIds.length})
</Button>
)}
{user?.role === "ADMIN" && ( {user?.role === "ADMIN" && (
<Button <Button
onClick={() => setIsUploadOpen(true)} onClick={() => setIsUploadOpen(true)}
@ -338,6 +363,8 @@ export const AssetsPage = () => {
onOpenViewer={openViewerModal} onOpenViewer={openViewerModal}
onDownload={handleDownload} onDownload={handleDownload}
onRequestDownload={handleRequestDownload} onRequestDownload={handleRequestDownload}
isSelected={selectedAssetIds.includes(asset.id)}
onToggleSelect={handleToggleSelectAsset}
/> />
</motion.div> </motion.div>
))} ))}
@ -367,8 +394,10 @@ export const AssetsPage = () => {
onClose={() => { onClose={() => {
setIsShareOpen(false); setIsShareOpen(false);
setActiveAsset(null); setActiveAsset(null);
setSelectedAssetIds([]);
}} }}
asset={activeAsset} asset={activeAsset}
assetIds={selectedAssetIds}
organizations={organizations} organizations={organizations}
onSuccess={fetchData} onSuccess={fetchData}
/> />

View File

@ -15,7 +15,7 @@ export const ClientAgreementsPage: React.FC = () => {
const ndaAcceptance = acceptances?.find(a => a.document.type === 'NDA'); const ndaAcceptance = acceptances?.find(a => a.document.type === 'NDA');
const msaAcceptance = acceptances?.find(a => a.document.type === 'MSA'); const msaAcceptance = acceptances?.find(a => a.document.type === 'MSA');
const fileHost = (import.meta.env.VITE_API_URL || 'http://localhost:5000/api/v1').replace('/api/v1', ''); const fileHost = (import.meta.env.VITE_API_URL || '/api/v1').replace('/api/v1', '');
// Header component // Header component
const headerNode = ( const headerNode = (
@ -80,8 +80,8 @@ export const ClientAgreementsPage: React.FC = () => {
)} )}
</div> </div>
<h3 className="text-base font-extrabold text-ink-900">Mutual Non-Disclosure Agreement (NDA)</h3> <h3 className="text-base agreement-card-title font-extrabold text-ink-900">Mutual Non-Disclosure Agreement (NDA)</h3>
<p className="text-xs text-ink-500 mt-1.5 leading-relaxed font-semibold"> <p className="text-xs agreement-card-desc text-ink-500 mt-1.5 leading-relaxed font-semibold">
Required to protect proprietary IP, silicon designs, and private data sharing during development. Required to protect proprietary IP, silicon designs, and private data sharing during development.
</p> </p>
@ -155,8 +155,8 @@ export const ClientAgreementsPage: React.FC = () => {
)} )}
</div> </div>
<h3 className="text-base font-extrabold text-ink-900">Master Services Agreement (MSA)</h3> <h3 className="text-base agreement-card-title font-extrabold text-ink-900">Master Services Agreement (MSA)</h3>
<p className="text-xs text-ink-500 mt-1.5 leading-relaxed font-semibold"> <p className="text-xs agreement-card-desc text-ink-500 mt-1.5 leading-relaxed font-semibold">
Defines the commercial framework, SLA guidelines, and consulting provisions for the partnership. Defines the commercial framework, SLA guidelines, and consulting provisions for the partnership.
</p> </p>

View File

@ -81,6 +81,7 @@ export const DashboardPage = () => {
className="p-5 space-y-6 flex flex-col min-h-0 flex-1 overflow-y-auto" className="p-5 space-y-6 flex flex-col min-h-0 flex-1 overflow-y-auto"
> >
{/* Stats Grid */} {/* Stats Grid */}
{user?.role === 'ADMIN' && (
<motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-2"> <motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-2">
{[ {[
{ label: 'Global Network Uptime', value: '99.99%', icon: Activity, trend: '+0.01%' }, { label: 'Global Network Uptime', value: '99.99%', icon: Activity, trend: '+0.01%' },
@ -99,9 +100,10 @@ export const DashboardPage = () => {
</div> </div>
))} ))}
</motion.div> </motion.div>
)}
{/* Main Action Cards */} {/* Main Action Cards */}
<motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-2"> <motion.div variants={itemVariants} className={`grid grid-cols-1 ${CARDS.length === 2 ? 'md:grid-cols-2' : 'md:grid-cols-3'} gap-4 pt-2`}>
{CARDS.map((card, idx) => ( {CARDS.map((card, idx) => (
<Link key={idx} to={card.path} className="group relative block h-full"> <Link key={idx} to={card.path} className="group relative block h-full">
<div className="relative h-full bg-ink-0 border border-ink-200 rounded-xl p-5 hover:border-ink-400 transition-all duration-300 overflow-hidden shadow-sm hover:shadow-md hover:-translate-y-0.5 flex flex-col justify-between"> <div className="relative h-full bg-ink-0 border border-ink-200 rounded-xl p-5 hover:border-ink-400 transition-all duration-300 overflow-hidden shadow-sm hover:shadow-md hover:-translate-y-0.5 flex flex-col justify-between">
@ -119,10 +121,10 @@ export const DashboardPage = () => {
<div className="inline-block px-2 py-0.5 rounded-md bg-ink-50 border border-ink-200 text-xs font-bold text-ink-500 mb-3 shadow-sm"> <div className="inline-block px-2 py-0.5 rounded-md bg-ink-50 border border-ink-200 text-xs font-bold text-ink-500 mb-3 shadow-sm">
{card.metrics} {card.metrics}
</div> </div>
<h3 className="text-lg font-bold text-ink-900 mb-2 tracking-tight"> <h3 className="text-lg action-card-title font-bold text-ink-900 mb-2 tracking-tight">
{card.title} {card.title}
</h3> </h3>
<p className="text-ink-500 leading-normal text-xs font-medium"> <p className="text-ink-500 action-card-desc leading-normal text-xs font-medium">
{card.description} {card.description}
</p> </p>
</div> </div>

View File

@ -2,13 +2,12 @@ import React, { useEffect, useState } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom'; import { useSearchParams, useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { Shield, CheckCircle, AlertCircle, ChevronRight, KeyRound, Eye, EyeOff } from 'lucide-react'; import { Shield, CheckCircle, AlertCircle, ChevronRight, KeyRound, Eye, EyeOff } from 'lucide-react';
import axios from 'axios';
import { axiosInstance } from '../services/axios'; import { axiosInstance } from '../services/axios';
import { useAuthStore } from '../hooks/use-auth'; import { useAuthStore } from '../hooks/use-auth';
import { useToast } from '../hooks/use-toast'; import { useToast } from '../hooks/use-toast';
export const InvitePage: React.FC = () => { export const InvitePage: React.FC = () => {
const { success, error: toastError } = useToast(); const { success } = useToast();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const token = searchParams.get('token'); const token = searchParams.get('token');
const navigate = useNavigate(); const navigate = useNavigate();
@ -29,9 +28,10 @@ export const InvitePage: React.FC = () => {
return; return;
} }
const validateToken = async () => { // verify token on mount
const verifyToken = async () => {
try { try {
const response = await axios.get(`${import.meta.env.VITE_API_URL || 'http://localhost:5000/api/v1'}/auth/invite/${token}`); const response = await axiosInstance.get(`/auth/verify-invite?token=${token}`);
setEmail(response.data.email); setEmail(response.data.email);
setStatus('valid'); setStatus('valid');
} catch (err) { } catch (err) {
@ -39,7 +39,7 @@ export const InvitePage: React.FC = () => {
} }
}; };
validateToken(); verifyToken();
}, [token]); }, [token]);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
@ -51,25 +51,36 @@ export const InvitePage: React.FC = () => {
return; return;
} }
if (password.length < 6) { if (password.length < 8) {
setError('Password must be at least 6 characters'); setError('Password must be at least 8 characters long');
return; return;
} }
setIsSubmitting(true); setIsSubmitting(true);
try { try {
const response = await axiosInstance.post('/auth/invite/accept', { const response = await axiosInstance.post('/auth/activate-partner', {
token, token,
password password
}); });
setAuth(response.data); // Show success toast
success("Account created successfully", "Please complete your partner onboarding profile."); success('Account activated successfully!');
// Set auth store
setAuth({ user: response.data.user, accessToken: response.data.token });
// Redirect based on role / status
setTimeout(() => {
if (response.data.user.role === 'ADMIN') {
navigate('/admin');
} else if (response.data.user.onboardingStatus === 'APPROVED') {
navigate('/client');
} else {
navigate('/onboarding'); navigate('/onboarding');
}
}, 1000);
} catch (err: any) { } catch (err: any) {
const errMsg = err.response?.data?.error || 'Failed to accept invite'; setError(err.response?.data?.detail || 'Activation failed');
setError(errMsg);
toastError("Failed to accept invite", errMsg);
setIsSubmitting(false); setIsSubmitting(false);
} }
}; };
@ -77,7 +88,7 @@ export const InvitePage: React.FC = () => {
if (status === 'loading') { if (status === 'loading') {
return ( return (
<div className="min-h-screen bg-ink-50 flex items-center justify-center"> <div className="min-h-screen bg-ink-50 flex items-center justify-center">
<div className="w-8 h-8 border-4 border-ink-900/30 border-t-ink-900 rounded-full animate-spin" /> <div className="w-8 h-8 border-4 border-ink-900/35 border-t-ink-900 rounded-full animate-spin" />
</div> </div>
); );
} }
@ -85,13 +96,13 @@ export const InvitePage: React.FC = () => {
if (status === 'invalid') { if (status === 'invalid') {
return ( return (
<div className="min-h-screen bg-ink-50 flex items-center justify-center p-4"> <div className="min-h-screen bg-ink-50 flex items-center justify-center p-4">
<div className="w-full max-w-md bg-ink-0 rounded-3xl p-8 border border-ink-200 text-center shadow-2xl"> <div className="w-full max-w-md bg-ink-0 rounded-[2rem] p-8 border border-ink-200 shadow-2xl text-center">
<div className="w-16 h-16 bg-red-500/10 rounded-full flex items-center justify-center mx-auto mb-6"> <div className="w-16 h-16 bg-red-500/10 rounded-full flex items-center justify-center mx-auto mb-6">
<AlertCircle className="w-8 h-8 text-red-600 dark:text-red-500" /> <AlertCircle className="w-8 h-8 text-red-600 dark:text-red-500" />
</div> </div>
<h2 className="text-2xl font-bold text-ink-900 mb-2">Invalid or Expired Link</h2> <h2 className="text-2xl login-form-title font-bold text-ink-900 mb-2">Invalid or Expired Link</h2>
<p className="text-ink-500 text-sm mb-8"> <p className="text-sm login-form-subtitle text-ink-500 max-w-md mx-auto mb-6">
This invitation link is no longer valid. Please request a new invitation from your administrator. The onboarding invitation link is invalid, expired, or has already been used. Please request a new invitation from your administrator.
</p> </p>
<button onClick={() => navigate('/login')} className="text-ink-900 font-extrabold hover:underline"> <button onClick={() => navigate('/login')} className="text-ink-900 font-extrabold hover:underline">
Return to Login Return to Login
@ -107,12 +118,12 @@ export const InvitePage: React.FC = () => {
<div className="fixed top-0 right-0 w-[500px] h-[500px] bg-ink-900/5 rounded-full blur-[120px] pointer-events-none" /> <div className="fixed top-0 right-0 w-[500px] h-[500px] bg-ink-900/5 rounded-full blur-[120px] pointer-events-none" />
<div className="w-full max-w-md z-10"> <div className="w-full max-w-md z-10">
<div className="text-center mb-10"> <div className="text-center mb-6">
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-ink-900 to-ink-800 shadow-xl flex items-center justify-center mx-auto mb-6"> <div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-ink-900 to-ink-800 shadow-xl flex items-center justify-center mx-auto mb-6">
<Shield className="w-6 h-6 text-ink-0" /> <Shield className="w-6 h-6 text-ink-0" />
</div> </div>
<h1 className="text-3xl font-extrabold tracking-tight mb-2">Welcome to Tech4Biz</h1> <h1 className="text-3xl login-title font-extrabold tracking-tight mb-2">Welcome to Tech4Biz</h1>
<p className="text-sm font-medium text-ink-500"> <p className="text-sm login-desc text-ink-500">
Set up your partner account for <span className="text-ink-900 font-bold">{email}</span> Set up your partner account for <span className="text-ink-900 font-bold">{email}</span>
</p> </p>
</div> </div>

View File

@ -5,7 +5,7 @@ import { loginUser } from "../services/auth-api";
import { useAuthStore } from "../hooks/use-auth"; import { useAuthStore } from "../hooks/use-auth";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useState } from "react"; import { useState } from "react";
import { Hexagon, Lock, Mail, ArrowRight, Eye, EyeOff } from "lucide-react"; import { Lock, Mail, ArrowRight, Eye, EyeOff } from "lucide-react";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { useToast } from "../hooks/use-toast"; import { useToast } from "../hooks/use-toast";
@ -71,16 +71,15 @@ export const LoginPage = () => {
> >
{/* Left Column - Partner Explanation */} {/* Left Column - Partner Explanation */}
<div className="md:col-span-6 lg:col-span-7 flex flex-col justify-center text-left"> <div className="md:col-span-6 lg:col-span-7 flex flex-col justify-center text-left">
<h1 className="text-3xl lg:text-4xl font-extrabold text-ink-900 tracking-tight leading-tight"> <div className="space-y-4 max-w-xl">
<span className="gradient-text">Tech4Biz Channel Partner</span> <h1 className="text-3xl login-title lg:text-4xl font-extrabold text-ink-900 tracking-tight leading-tight">
Enterprise Scale, Secure Delivery
</h1> </h1>
<p className="text-ink-500 text-sm mt-5 font-medium leading-relaxed max-w-lg"> <p className="text-ink-500 text-sm login-desc mt-5 font-medium leading-relaxed max-w-lg">
Expand your business by partnering with Tech4Biz and unlock new Tech4Biz Channel Partner Portal facilitates authenticated, low-latency distribution of physical asset coordinates, design assets, and cryptographically verified legal agreements.
opportunities for growth through our innovative technology
solutions. Join our partner network to access exclusive resources,
dedicated support, and a platform designed to help you succeed.
</p> </p>
</div> </div>
</div>
{/* Right Column - Login Component */} {/* Right Column - Login Component */}
<div className="md:col-span-6 lg:col-span-5 w-full max-w-sm justify-self-center md:justify-self-end"> <div className="md:col-span-6 lg:col-span-5 w-full max-w-sm justify-self-center md:justify-self-end">
@ -88,11 +87,11 @@ export const LoginPage = () => {
<div className="absolute top-0 inset-x-0 h-px bg-gradient-to-r from-transparent via-ink-300 to-transparent" /> <div className="absolute top-0 inset-x-0 h-px bg-gradient-to-r from-transparent via-ink-300 to-transparent" />
<div className="flex flex-col items-center mb-8 text-center"> <div className="flex flex-col items-center mb-8 text-center">
<div className="relative flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-tr from-ink-900 to-ink-800 shadow-md mb-6"> <div className="relative flex items-center justify-center w-16 h-16 rounded-2xl bg-ink-0 border border-ink-200 shadow-sm mb-6 p-2">
<Hexagon className="text-ink-0 w-8 h-8 absolute" /> <img src="/logo.png" alt="Tech4Biz" className="w-full h-full object-contain" />
</div> </div>
<h2 className="text-2xl font-bold text-ink-900 tracking-tight"> <h2 className="text-2xl login-form-title font-bold text-ink-900 tracking-tight">
Channel Portal Partner Identity Gateway
</h2> </h2>
<p className="text-ink-500 text-[10px] mt-1.5 font-bold uppercase tracking-wider"> <p className="text-ink-500 text-[10px] mt-1.5 font-bold uppercase tracking-wider">
Authorized Access Only Authorized Access Only

View File

@ -90,7 +90,7 @@ export const OnboardingPage: React.FC = () => {
}; };
const renderDocumentViewer = (type: DocumentType) => { const renderDocumentViewer = (type: DocumentType) => {
const fileHost = (import.meta.env.VITE_API_URL || 'http://localhost:5000/api/v1').replace('/api/v1', ''); const fileHost = (import.meta.env.VITE_API_URL || '/api/v1').replace('/api/v1', '');
const pdfUrl = type === 'NDA' ? ndaPdfUrl : msaPdfUrl; const pdfUrl = type === 'NDA' ? ndaPdfUrl : msaPdfUrl;
if (pdfUrl) { if (pdfUrl) {
@ -401,9 +401,9 @@ export const OnboardingPage: React.FC = () => {
<Lock className="w-3.5 h-3.5 text-ink-0" /> <Lock className="w-3.5 h-3.5 text-ink-0" />
<span className="text-[10px] font-bold text-ink-0 tracking-widest uppercase">Action Required</span> <span className="text-[10px] font-bold text-ink-0 tracking-widest uppercase">Action Required</span>
</div> </div>
<h2 className="text-3xl font-extrabold tracking-tight mb-2">Non-Disclosure Agreement</h2> <h2 className="text-3xl login-title font-extrabold tracking-tight mb-2">Non-Disclosure Agreement</h2>
<p className="text-sm font-medium text-ink-500"> <p className="text-sm login-desc text-ink-500 mt-1.5 font-medium max-w-md">
Please provide your signature or upload a signed copy of our standard NDA to proceed. Please review the mutual NDA terms carefully before signing.
</p> </p>
</div> </div>
@ -431,9 +431,9 @@ export const OnboardingPage: React.FC = () => {
<FileText className="w-3.5 h-3.5 text-ink-600" /> <FileText className="w-3.5 h-3.5 text-ink-600" />
<span className="text-[10px] font-bold text-ink-700 tracking-widest uppercase">Final Agreement</span> <span className="text-[10px] font-bold text-ink-700 tracking-widest uppercase">Final Agreement</span>
</div> </div>
<h2 className="text-3xl font-extrabold tracking-tight mb-2">Master Services Agreement</h2> <h2 className="text-3xl login-title font-extrabold tracking-tight mb-2">Master Services Agreement</h2>
<p className="text-sm font-medium text-ink-500"> <p className="text-sm login-desc text-ink-500 mt-1.5 font-medium max-w-md">
Sign the MSA to finalize your compliance requirements and enter the approval queue. Please review the master services partnership terms before signing.
</p> </p>
</div> </div>

View File

@ -341,8 +341,8 @@ export const DirectoryPage: React.FC = () => {
<tr> <tr>
<th className="px-5 py-3">Partner</th> <th className="px-5 py-3">Partner</th>
<th className="px-5 py-3">Status</th> <th className="px-5 py-3">Status</th>
<th className="px-5 py-3">MFA</th> <th className="px-5 py-3 hidden sm:table-cell">MFA</th>
<th className="px-5 py-3">Joined</th> <th className="px-5 py-3 hidden sm:table-cell">Joined</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-ink-200 bg-ink-0"> <tbody className="divide-y divide-ink-200 bg-ink-0">
@ -391,7 +391,7 @@ export const DirectoryPage: React.FC = () => {
{sc.label} {sc.label}
</span> </span>
</td> </td>
<td className="px-5 py-4"> <td className="px-5 py-4 hidden sm:table-cell">
{partner.mfaEnabled ? ( {partner.mfaEnabled ? (
<span className="text-xs font-bold text-ink-900"> <span className="text-xs font-bold text-ink-900">
Enabled Enabled
@ -402,7 +402,7 @@ export const DirectoryPage: React.FC = () => {
</span> </span>
)} )}
</td> </td>
<td className="px-5 py-4 text-xs text-ink-500 font-medium"> <td className="px-5 py-4 text-xs text-ink-500 font-medium hidden sm:table-cell">
{new Date(partner.createdAt).toLocaleDateString()} {new Date(partner.createdAt).toLocaleDateString()}
</td> </td>
</tr> </tr>

View File

@ -125,7 +125,7 @@ export const LegalTemplatesPage: React.FC = () => {
}; };
const currentDoc = activeTab === 'NDA' ? ndaDoc : msaDoc; const currentDoc = activeTab === 'NDA' ? ndaDoc : msaDoc;
const fileHost = (import.meta.env.VITE_API_URL || 'http://localhost:5000/api/v1').replace('/api/v1', ''); const fileHost = (import.meta.env.VITE_API_URL || '/api/v1').replace('/api/v1', '');
// Header component // Header component
const headerNode = ( const headerNode = (

View File

@ -52,3 +52,10 @@ export const rejectDownloadRequest = async (assetId: string, requestId: string):
export const downloadAssetFile = async (id: string): Promise<void> => { export const downloadAssetFile = async (id: string): Promise<void> => {
await axiosInstance.post(`/assets/${id}/download`); await axiosInstance.post(`/assets/${id}/download`);
}; };
export const bulkShareAssets = async (
assetIds: string[],
shares: Array<{ organizationId: string; userId: string | null }>
): Promise<void> => {
await axiosInstance.patch('/assets/bulk-share', { assetIds, shares });
};

View File

@ -1,7 +1,7 @@
import axios from 'axios'; import axios from 'axios';
export const axiosInstance = axios.create({ export const axiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:5000/api/v1', baseURL: import.meta.env.VITE_API_URL || '/api/v1',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },

View File

@ -7,8 +7,21 @@ export default defineConfig({
plugins: [tailwindcss(), react()], plugins: [tailwindcss(), react()],
server: { server: {
allowedHosts: [ allowedHosts: [
"toughly-coinstantaneous-dimple.ngrok-free.dev" "toughly-coinstantaneous-dimple.ngrok-free.dev",
"spruce-fridge-destiny.ngrok-free.dev"
], ],
cors: true cors: true,
proxy: {
'/api': {
target: 'http://localhost:5000',
changeOrigin: true,
secure: false,
},
'/uploads': {
target: 'http://localhost:5000',
changeOrigin: true,
secure: false,
}
}
} }
}); });

View File

@ -1,32 +1,76 @@
# React + TypeScript + Vite # Tech4Biz Channel Partner Onboarding & Secure Asset Management
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules. An enterprise-grade, secure, multi-tenant portal designed for onboarding channel partners, managing digital assets, and orchestrating NDA/MSA legal workflows.
Currently, two official plugins are available: ---
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) ## 🛠️ Technology Stack
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) * **Frontend**: React (Vite, TypeScript, Tailwind CSS, Axios)
* **Backend**: Node.js (Express, TypeScript, Prisma ORM, JWT, Helmet)
* **Databases**: PostgreSQL (Relational metadata storage)
* **Object Storage**: MinIO (Private, S3-compatible asset storage container)
## React Compiler ---
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). ## 🚀 Quick Start (Development)
## Expanding the Oxlint configuration ### Prerequisites
* Ensure **Docker** and **Node.js** are installed and running.
* The startup scripts automatically detect and terminate any processes occupying backend/frontend ports to prevent crash loops.
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`: ### Step 1: Start Backend, Database, & Storage
From the project root:
```bash
./run-backend.sh
```
This script will:
1. Spin up the Postgres and MinIO containers.
2. Generate environment variables.
3. Align the database schemas via Prisma.
4. Run database seeders (Admin/Partner mock accounts).
5. **Automatically seed the MinIO bucket with static documents.**
6. Free port `5000` (if in use) and launch the backend server.
```json ### Step 2: Start Frontend App
{ From the project root:
"$schema": "./node_modules/oxlint/configuration_schema.json", ```bash
"plugins": ["react", "typescript", "oxc"], ./run-frontend.sh
"options": { ```
"typeAware": true This script will resolve dependencies, free port `5173` (if in use), and launch the Vite development server on [http://localhost:5173](http://localhost:5173).
},
"rules": { ---
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }] ## 📂 MinIO Seeding & Data Transfer
}
} ### How Asset Seeding Works
To ensure that digital assets (like product guides and NDA templates) are transferred when cloning the repository:
1. Standard assets are committed to the **`minio-seed/`** directory in the repository root (not ignored by Git).
2. During server start (via `./run-backend.sh`), the backend runs the S3 seeder command:
```bash
npx ts-node src/seed-minio.ts
```
3. The script initializes the `secure-assets` bucket in MinIO and uploads any files found in the local `/minio-seed/` folder to the bucket, preserving exact filenames, MIME types, and sizes.
### Manual Seeding / Restore Command
If you ever reset your MinIO volume and need to re-populate the S3 bucket with the committed repository assets, run the following commands:
```bash
cd Channel-Backend
npx ts-node src/seed-minio.ts
``` ```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories. ---
## 🔑 Default Credentials & Admin Ports
### Web Interfaces
* **Frontend Dashboard**: [http://localhost:5173](http://localhost:5173)
* **MinIO Console Dashboard**: [http://localhost:9090](http://localhost:9090)
### Login Credentials
| System | User/ID | Password/Secret |
| :--- | :--- | :--- |
| **Admin Account** | `admin@tech4biz.com` | `Password123` |
| **Partner (Approved)** | `active-partner@partner.com` | `Password123` |
| **Partner (Pending NDA)** | `pending-onboarding@partner.com` | `Password123` |
| **MinIO Console** | `minio_admin` | `minio_password_2026` |
| **JWT Secrets** | *Auto-generated in `.env`* | `super-secret-jwt-key-2026-world-class` |

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

Binary file not shown.

View File

@ -76,6 +76,9 @@ npx prisma db push
info "Running database seeding..." info "Running database seeding..."
npx ts-node seed.ts npx ts-node seed.ts
info "Running MinIO storage seeding..."
npx ts-node src/seed-minio.ts
# Check if port 5000 is occupied and free it # Check if port 5000 is occupied and free it
if lsof -i :5000 &> /dev/null; then if lsof -i :5000 &> /dev/null; then
PORT_PID=$(lsof -t -i :5000) PORT_PID=$(lsof -t -i :5000)

View File

@ -34,4 +34,4 @@ fi
# 3. Start Vite Dev Server # 3. Start Vite Dev Server
info "Starting Vite frontend dev server..." info "Starting Vite frontend dev server..."
npm run dev npm run dev -- --host