353 lines
12 KiB
TypeScript
353 lines
12 KiB
TypeScript
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 { useAuthStore } from '../hooks/use-auth';
|
|
import { axiosInstance } from '../services/axios';
|
|
import {
|
|
getAssets,
|
|
getOrganizations,
|
|
deleteAsset,
|
|
requestDownload,
|
|
approveDownloadRequest,
|
|
rejectDownloadRequest,
|
|
downloadAssetFile
|
|
} from '../services/assets-api';
|
|
|
|
// Subcomponents
|
|
import { AssetCard } from '../features/assets/components/AssetCard';
|
|
import { UploadAssetModal } from '../features/assets/components/UploadAssetModal';
|
|
import { EditAssetModal } from '../features/assets/components/EditAssetModal';
|
|
import { ShareAssetModal } from '../features/assets/components/ShareAssetModal';
|
|
import { AssetDetailsModal } from '../features/assets/components/AssetDetailsModal';
|
|
import { AssetViewerModal } from '../features/assets/components/AssetViewerModal';
|
|
import { DownloadRequestsModal } from '../features/assets/components/DownloadRequestsModal';
|
|
import { PageHeader } from '../components/ui/PageHeader';
|
|
|
|
// Type Definitions
|
|
import type { Asset, Organization } from '../types/assets';
|
|
|
|
const containerVariants: Variants = {
|
|
hidden: { opacity: 0 },
|
|
show: { opacity: 1, transition: { staggerChildren: 0.05 } }
|
|
};
|
|
|
|
const itemVariants: Variants = {
|
|
hidden: { opacity: 0, y: 15, scale: 0.98 },
|
|
show: { opacity: 1, y: 0, scale: 1, transition: { type: 'spring', stiffness: 350, damping: 25 } }
|
|
};
|
|
|
|
export const AssetsPage = () => {
|
|
const user = useAuthStore((state) => state.user);
|
|
const [assets, setAssets] = useState<Asset[]>([]);
|
|
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
// Search & Filter
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [selectedCategory, setSelectedCategory] = useState<string>('ALL');
|
|
|
|
// Modal Control States
|
|
const [isUploadOpen, setIsUploadOpen] = useState(false);
|
|
const [isEditOpen, setIsEditOpen] = useState(false);
|
|
const [isShareOpen, setIsShareOpen] = useState(false);
|
|
const [isDetailsOpen, setIsDetailsOpen] = useState(false);
|
|
const [isViewerOpen, setIsViewerOpen] = useState(false);
|
|
const [isRequestsOpen, setIsRequestsOpen] = useState(false);
|
|
|
|
const [activeAsset, setActiveAsset] = useState<Asset | null>(null);
|
|
const [activeMenuId, setActiveMenuId] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
fetchData();
|
|
}, []);
|
|
|
|
const fetchData = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const assetsData = await getAssets();
|
|
setAssets(assetsData);
|
|
|
|
if (user?.role === 'ADMIN') {
|
|
const orgsData = await getOrganizations();
|
|
setOrganizations(orgsData);
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to fetch assets data', err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const openEditModal = (asset: Asset) => {
|
|
setActiveAsset(asset);
|
|
setIsEditOpen(true);
|
|
};
|
|
|
|
const openShareModal = (asset: Asset) => {
|
|
setActiveAsset(asset);
|
|
setIsShareOpen(true);
|
|
};
|
|
|
|
const openViewerModal = (asset: Asset) => {
|
|
setActiveAsset(asset);
|
|
setIsViewerOpen(true);
|
|
};
|
|
|
|
const openDetailsModal = (asset: Asset) => {
|
|
setActiveAsset(asset);
|
|
setIsDetailsOpen(true);
|
|
};
|
|
|
|
const handleDeleteAsset = async (id: string) => {
|
|
if (!window.confirm('Are you sure you want to permanently delete this asset?')) return;
|
|
try {
|
|
await deleteAsset(id);
|
|
await fetchData();
|
|
} catch (err) {
|
|
console.error('Failed to delete asset', err);
|
|
}
|
|
};
|
|
|
|
const handleRequestDownload = async (asset: Asset) => {
|
|
try {
|
|
await requestDownload(asset.id);
|
|
await fetchData();
|
|
} catch (err) {
|
|
console.error('Failed to request download access', err);
|
|
}
|
|
};
|
|
|
|
const handleApproveRequest = async (assetId: string, requestId: string) => {
|
|
try {
|
|
await approveDownloadRequest(assetId, requestId);
|
|
await fetchData();
|
|
} catch (err) {
|
|
console.error('Failed to approve request', err);
|
|
}
|
|
};
|
|
|
|
const handleRejectRequest = async (assetId: string, requestId: string) => {
|
|
try {
|
|
await rejectDownloadRequest(assetId, requestId);
|
|
await fetchData();
|
|
} catch (err) {
|
|
console.error('Failed to reject request', err);
|
|
}
|
|
};
|
|
|
|
const handleDownload = async (asset: Asset) => {
|
|
try {
|
|
await downloadAssetFile(asset.id);
|
|
|
|
const downloadUrl = asset.url.startsWith('http')
|
|
? asset.url
|
|
: `${axiosInstance.defaults.baseURL?.replace('/api/v1', '')}${asset.url}`;
|
|
|
|
const a = document.createElement('a');
|
|
a.href = downloadUrl;
|
|
a.download = asset.title;
|
|
a.target = '_blank';
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
|
|
setAssets(prev => prev.map(item =>
|
|
item.id === asset.id ? { ...item, downloadsCount: item.downloadsCount + 1 } : item
|
|
));
|
|
} catch (err) {
|
|
console.error('Failed to process download', err);
|
|
}
|
|
};
|
|
|
|
const CATEGORIES = ['ALL', 'Marketing', 'Presentations', 'Branding', 'Resources', 'Technical'];
|
|
|
|
const filteredAssets = assets.filter(asset => {
|
|
const query = searchQuery.toLowerCase().trim();
|
|
|
|
const matchesSearch = !query ||
|
|
asset.title.toLowerCase().includes(query) ||
|
|
(asset.description && asset.description.toLowerCase().includes(query)) ||
|
|
(asset.categoryId && asset.categoryId.toLowerCase().includes(query)) ||
|
|
(asset.subcategory && asset.subcategory.toLowerCase().includes(query)) ||
|
|
(asset.githubUrl && asset.githubUrl.toLowerCase().includes(query)) ||
|
|
asset.type.toLowerCase().includes(query) ||
|
|
asset.tags.some(tag => tag.toLowerCase().includes(query));
|
|
|
|
const matchesCategory =
|
|
selectedCategory === 'ALL' ||
|
|
asset.categoryId === selectedCategory;
|
|
|
|
return matchesSearch && matchesCategory;
|
|
});
|
|
|
|
const pendingRequestsCount = assets.reduce((acc, asset) => {
|
|
return acc + (asset.downloadRequests?.filter(r => r.status === 'PENDING').length || 0);
|
|
}, 0);
|
|
|
|
return (
|
|
<motion.div variants={containerVariants} initial="hidden" animate="show" className="w-full space-y-6 text-ink-900 animate-fade-in">
|
|
|
|
<PageHeader
|
|
title="Asset Library"
|
|
subtitle="Securely manage, distribute, and track marketing collateral and partner resources."
|
|
badge={
|
|
<div className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full bg-ink-100 border border-ink-200 text-[10px] font-bold text-ink-900 tracking-wider uppercase shrink-0">
|
|
<CheckCircle className="w-3.5 h-3.5 text-ink-950" />
|
|
<span>Global CDN Active</span>
|
|
</div>
|
|
}
|
|
actions={
|
|
<>
|
|
{user?.role === 'ADMIN' && pendingRequestsCount > 0 && (
|
|
<button
|
|
onClick={() => setIsRequestsOpen(true)}
|
|
className="bg-ink-100 text-ink-900 border border-ink-300 font-bold py-1.5 px-3 rounded-lg hover:bg-ink-200 transition-all flex items-center justify-center gap-2 relative shadow-sm text-xs cursor-pointer"
|
|
>
|
|
<span>Download Requests</span>
|
|
<span className="w-4 h-4 rounded-full bg-ink-900 text-ink-0 text-[10px] flex items-center justify-center font-extrabold">
|
|
{pendingRequestsCount}
|
|
</span>
|
|
</button>
|
|
)}
|
|
|
|
{user?.role === 'ADMIN' && (
|
|
<button
|
|
onClick={() => setIsUploadOpen(true)}
|
|
className="group relative bg-ink-900 text-ink-0 font-bold py-1.5 px-3 rounded-lg hover:bg-ink-800 transition-all duration-300 overflow-hidden flex items-center justify-center gap-2 shadow-sm text-xs cursor-pointer"
|
|
>
|
|
<UploadCloud className="w-3.5 h-3.5" />
|
|
<span>Create / Upload Asset</span>
|
|
</button>
|
|
)}
|
|
</>
|
|
}
|
|
/>
|
|
|
|
{/* Action Bar */}
|
|
<motion.div variants={itemVariants} className="flex flex-col md:flex-row gap-4 items-center pt-2">
|
|
<div className="relative flex-1 w-full group">
|
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
<Search className="w-4 h-4 text-ink-400 group-focus-within:text-ink-900 transition-colors" />
|
|
</div>
|
|
<input
|
|
type="text"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="w-full bg-ink-0 border border-ink-200 rounded-lg py-1.5 pl-9 pr-4 text-xs font-semibold text-ink-900 placeholder-ink-400 outline-none transition-all focus:border-ink-900/50 focus:ring-2 ring-ink-900/10 shadow-sm hover:border-ink-300"
|
|
placeholder="Search by title, desc, tag, category, URL..."
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex gap-2 w-full md:w-auto overflow-x-auto pb-1 md:pb-0 scrollbar-none">
|
|
{CATEGORIES.map((cat) => (
|
|
<button
|
|
key={cat}
|
|
onClick={() => setSelectedCategory(cat)}
|
|
className={`px-3 py-1.5 rounded-lg border text-xs font-bold transition-all whitespace-nowrap shadow-sm cursor-pointer ${
|
|
selectedCategory === cat
|
|
? 'bg-ink-900 text-ink-0 border-ink-900'
|
|
: 'bg-ink-0 text-ink-700 border-ink-200 hover:bg-ink-50'
|
|
}`}
|
|
>
|
|
{cat}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</motion.div>
|
|
|
|
{/* Grid Content */}
|
|
{loading ? (
|
|
<div className="py-20 flex justify-center items-center">
|
|
<div className="w-8 h-8 border-4 border-ink-900/30 border-t-ink-900 rounded-full animate-spin" />
|
|
</div>
|
|
) : filteredAssets.length === 0 ? (
|
|
<div className="py-12 text-center bg-ink-0 border border-ink-200 rounded-xl">
|
|
<File className="w-12 h-12 text-ink-300 mx-auto mb-4" />
|
|
<h3 className="text-lg font-bold text-ink-900">No assets found</h3>
|
|
<p className="text-ink-500 text-sm mt-1">There are no assets matching your criteria.</p>
|
|
</div>
|
|
) : (
|
|
<motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4 pt-2">
|
|
{filteredAssets.map((asset) => (
|
|
<AssetCard
|
|
key={asset.id}
|
|
asset={asset}
|
|
user={user}
|
|
activeMenuId={activeMenuId}
|
|
setActiveMenuId={setActiveMenuId}
|
|
onViewDetails={openDetailsModal}
|
|
onEdit={openEditModal}
|
|
onShare={openShareModal}
|
|
onDelete={handleDeleteAsset}
|
|
onOpenViewer={openViewerModal}
|
|
onDownload={handleDownload}
|
|
onRequestDownload={handleRequestDownload}
|
|
/>
|
|
))}
|
|
</motion.div>
|
|
)}
|
|
|
|
{/* Modals Container */}
|
|
<UploadAssetModal
|
|
isOpen={isUploadOpen}
|
|
onClose={() => setIsUploadOpen(false)}
|
|
onSuccess={fetchData}
|
|
/>
|
|
|
|
<EditAssetModal
|
|
isOpen={isEditOpen}
|
|
onClose={() => {
|
|
setIsEditOpen(false);
|
|
setActiveAsset(null);
|
|
}}
|
|
asset={activeAsset}
|
|
onSuccess={fetchData}
|
|
/>
|
|
|
|
<ShareAssetModal
|
|
isOpen={isShareOpen}
|
|
onClose={() => {
|
|
setIsShareOpen(false);
|
|
setActiveAsset(null);
|
|
}}
|
|
asset={activeAsset}
|
|
organizations={organizations}
|
|
onSuccess={fetchData}
|
|
/>
|
|
|
|
<AssetDetailsModal
|
|
isOpen={isDetailsOpen}
|
|
onClose={() => {
|
|
setIsDetailsOpen(false);
|
|
setActiveAsset(null);
|
|
}}
|
|
asset={activeAsset}
|
|
userRole={user?.role}
|
|
/>
|
|
|
|
<AssetViewerModal
|
|
isOpen={isViewerOpen}
|
|
onClose={() => {
|
|
setIsViewerOpen(false);
|
|
setActiveAsset(null);
|
|
}}
|
|
asset={activeAsset}
|
|
user={user}
|
|
onDownload={handleDownload}
|
|
/>
|
|
|
|
<DownloadRequestsModal
|
|
isOpen={isRequestsOpen}
|
|
onClose={() => setIsRequestsOpen(false)}
|
|
assets={assets}
|
|
onApprove={handleApproveRequest}
|
|
onReject={handleRejectRequest}
|
|
/>
|
|
|
|
</motion.div>
|
|
);
|
|
};
|
|
|
|
export default AssetsPage;
|