diff --git a/Channel-Backend/src/controllers/asset.controller.ts b/Channel-Backend/src/controllers/asset.controller.ts index 6f7979f..b7d9abd 100644 --- a/Channel-Backend/src/controllers/asset.controller.ts +++ b/Channel-Backend/src/controllers/asset.controller.ts @@ -88,6 +88,20 @@ export class AssetController { } 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) => { try { const { organizationIds } = req.body; diff --git a/Channel-Backend/src/routes/asset.routes.ts b/Channel-Backend/src/routes/asset.routes.ts index 1787265..811fbc4 100644 --- a/Channel-Backend/src/routes/asset.routes.ts +++ b/Channel-Backend/src/routes/asset.routes.ts @@ -10,6 +10,7 @@ router.use(authenticate); router.post('/upload', requireRole('ADMIN'), upload.single('file'), assetController.uploadAsset); router.get('/', assetController.listAssets); +router.patch('/bulk-share', requireRole('ADMIN'), assetController.bulkShareAssets); router.get('/:id', assetController.getAsset); router.patch('/:id', requireRole('ADMIN'), assetController.updateAsset); router.post('/:id/share', requireRole('ADMIN'), assetController.shareAsset); diff --git a/Channel-Backend/src/services/asset.service.ts b/Channel-Backend/src/services/asset.service.ts index 0b89304..55eb94b 100644 --- a/Channel-Backend/src/services/asset.service.ts +++ b/Channel-Backend/src/services/asset.service.ts @@ -267,6 +267,24 @@ export class AssetService { 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) { return await prisma.asset.update({ where: { id }, diff --git a/Channel-Frontend/src/features/assets/components/AssetCard.tsx b/Channel-Frontend/src/features/assets/components/AssetCard.tsx index 12cc519..7ab232c 100644 --- a/Channel-Frontend/src/features/assets/components/AssetCard.tsx +++ b/Channel-Frontend/src/features/assets/components/AssetCard.tsx @@ -30,6 +30,8 @@ interface AssetCardProps { onOpenViewer: (asset: Asset) => void; onDownload: (asset: Asset) => void; onRequestDownload: (asset: Asset) => void; + isSelected?: boolean; + onToggleSelect?: (assetId: string) => void; } export const AssetCard: React.FC = ({ @@ -43,7 +45,9 @@ export const AssetCard: React.FC = ({ onDelete, onOpenViewer, onDownload, - onRequestDownload + onRequestDownload, + isSelected = false, + onToggleSelect }) => { const isMenuOpen = activeMenuId === asset.id; const canDirectDownload = user?.role === 'ADMIN' || asset.isDownloadable || asset.downloadRequests?.[0]?.status === 'APPROVED'; @@ -72,11 +76,21 @@ export const AssetCard: React.FC = ({ const Icon = getAssetIcon(asset.type); return ( -
+
-
- +
+ {user?.role === 'ADMIN' && onToggleSelect && ( + onToggleSelect(asset.id)} + className="w-3.5 h-3.5 rounded border-ink-300 text-ink-900 focus:ring-ink-900/10 cursor-pointer mr-1" + /> + )} +
+ +
diff --git a/Channel-Frontend/src/features/assets/components/ShareAssetModal.tsx b/Channel-Frontend/src/features/assets/components/ShareAssetModal.tsx index 5e7d0d9..fc0d883 100644 --- a/Channel-Frontend/src/features/assets/components/ShareAssetModal.tsx +++ b/Channel-Frontend/src/features/assets/components/ShareAssetModal.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react'; 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 Modal from '../../../components/ui/Modal'; import Button from '../../../components/ui/Button'; @@ -11,6 +11,7 @@ interface ShareAssetModalProps { isOpen: boolean; onClose: () => void; asset: Asset | null; + assetIds?: string[] | null; organizations: Organization[]; onSuccess: () => void; } @@ -19,6 +20,7 @@ export const ShareAssetModal: React.FC = ({ isOpen, onClose, asset, + assetIds, organizations, onSuccess }) => { @@ -35,8 +37,10 @@ export const ShareAssetModal: React.FC = ({ userId: s.userId })) || [] ); + } else { + setSharesList([]); } - }, [asset]); + }, [asset, isOpen]); const isOrgSharedEntirely = (orgId: string) => { return sharesList.some(s => s.organizationId === orgId && s.userId === null); @@ -72,13 +76,17 @@ export const ShareAssetModal: React.FC = ({ const handleShareSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (!asset) return; + if (!asset && (!assetIds || assetIds.length === 0)) return; setIsSavingShare(true); try { - await updateAsset(asset.id, { - shares: sharesList - }); + if (asset) { + await updateAsset(asset.id, { + shares: sharesList + }); + } else if (assetIds && assetIds.length > 0) { + await bulkShareAssets(assetIds, sharesList); + } success('Share permissions updated', 'The asset visibility settings have been updated.'); onSuccess(); onClose(); @@ -92,10 +100,10 @@ export const ShareAssetModal: React.FC = ({ return ( 0))} onClose={onClose} title="Share Settings" - subtitle={asset?.title} + subtitle={asset ? asset.title : `${assetIds?.length || 0} selected assets`} size="md" footer={ <> @@ -119,7 +127,7 @@ export const ShareAssetModal: React.FC = ({ } > - {asset && ( + {(asset || (assetIds && assetIds.length > 0)) && (

Select organizations or expand to specify exact users that can access this asset: diff --git a/Channel-Frontend/src/pages/AssetsPage.tsx b/Channel-Frontend/src/pages/AssetsPage.tsx index 74f31c5..bf7cde5 100644 --- a/Channel-Frontend/src/pages/AssetsPage.tsx +++ b/Channel-Frontend/src/pages/AssetsPage.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from "react"; import { motion } 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 { axiosInstance } from "../services/axios"; import { @@ -66,6 +66,7 @@ export const AssetsPage = () => { const [activeAsset, setActiveAsset] = useState(null); const [activeMenuId, setActiveMenuId] = useState(null); + const [selectedAssetIds, setSelectedAssetIds] = useState([]); useEffect(() => { fetchData(); @@ -76,6 +77,7 @@ export const AssetsPage = () => { try { const assetsData = await getAssets(); setAssets(assetsData); + setSelectedAssetIds([]); if (user?.role === "ADMIN") { const orgsData = await getOrganizations(); @@ -94,10 +96,19 @@ export const AssetsPage = () => { }; const openShareModal = (asset: Asset) => { + setSelectedAssetIds([]); setActiveAsset(asset); setIsShareOpen(true); }; + const handleToggleSelectAsset = (assetId: string) => { + setSelectedAssetIds((prev) => + prev.includes(assetId) + ? prev.filter((id) => id !== assetId) + : [...prev, assetId] + ); + }; + const openViewerModal = (asset: Asset) => { setActiveAsset(asset); setIsViewerOpen(true); @@ -288,6 +299,20 @@ export const AssetsPage = () => { )} + {user?.role === "ADMIN" && selectedAssetIds.length > 0 && ( + + )} + {user?.role === "ADMIN" && (