567 lines
21 KiB
TypeScript
567 lines
21 KiB
TypeScript
import { useState, useEffect } from "react";
|
|
import { motion } from "framer-motion";
|
|
import type { Variants } from "framer-motion";
|
|
import { UploadCloud, Search, File, CheckCircle, Share2, Folder, Sparkles } from "lucide-react";
|
|
import { useAuthStore } from "../hooks/use-auth";
|
|
import { axiosInstance } from "../services/axios";
|
|
import {
|
|
getAssets,
|
|
getOrganizations,
|
|
deleteAsset,
|
|
requestDownload,
|
|
approveDownloadRequest,
|
|
rejectDownloadRequest,
|
|
downloadAssetFile,
|
|
getAssetGroups,
|
|
} from "../services/assets-api";
|
|
import type { AssetGroup } from "../services/assets-api";
|
|
import { useToast } from "../hooks/use-toast";
|
|
|
|
// 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 { ManageGroupsModal } from "../features/assets/components/ManageGroupsModal";
|
|
import { PageHeader } from "../components/ui/PageHeader";
|
|
import Button from "../components/ui/Button";
|
|
import { PageLayout } from "../components/layout/PageLayout";
|
|
import Modal from "../components/ui/Modal";
|
|
|
|
// 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 { success, error } = useToast();
|
|
const user = useAuthStore((state) => state.user);
|
|
const [assets, setAssets] = useState<Asset[]>([]);
|
|
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
const [groups, setGroups] = useState<AssetGroup[]>([]);
|
|
|
|
// Search & Filter
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const [selectedCategory, setSelectedCategory] = useState<string>("ALL");
|
|
|
|
// Find recommended assets based on user's partnerGroup matching any AssetGroup.name (supports comma-separated multiple groups)
|
|
const partnerGroupStrings = user?.partnerGroup && user.role === "PARTNER_USER"
|
|
? user.partnerGroup.split(',').map(s => s.trim().toLowerCase())
|
|
: [];
|
|
|
|
// Only recommend assets that the partner actually has permission to access, and deduplicate
|
|
const recommendedAssets = (() => {
|
|
if (partnerGroupStrings.length === 0) return [];
|
|
|
|
const activeGroups = groups.filter(g => partnerGroupStrings.includes(g.name.trim().toLowerCase()));
|
|
|
|
return activeGroups
|
|
.flatMap(g => g.assets)
|
|
.filter((recAsset, index, self) =>
|
|
self.findIndex(a => a.id === recAsset.id) === index &&
|
|
assets.some(allAsset => allAsset.id === recAsset.id)
|
|
);
|
|
})();
|
|
|
|
// 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 [isGroupsOpen, setIsGroupsOpen] = useState(false);
|
|
|
|
const [activeAsset, setActiveAsset] = useState<Asset | null>(null);
|
|
const [activeMenuId, setActiveMenuId] = useState<string | null>(null);
|
|
const [selectedAssetIds, setSelectedAssetIds] = useState<string[]>([]);
|
|
const [expandedAssetId, setExpandedAssetId] = useState<string | null>(null);
|
|
const [assetToDelete, setAssetToDelete] = useState<{ id: string, title: string } | null>(null);
|
|
|
|
useEffect(() => {
|
|
fetchData();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (selectedCategory !== "ALL" && selectedCategory !== "RECOMMENDED") {
|
|
setSelectedCategory("ALL");
|
|
}
|
|
}, [assets, selectedCategory]);
|
|
|
|
const fetchData = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const assetsData = await getAssets();
|
|
setAssets(assetsData);
|
|
setSelectedAssetIds([]);
|
|
|
|
// Load groups for both ADMIN (for managing) and PARTNER (for recommendations)
|
|
const groupsData = await getAssetGroups();
|
|
setGroups(groupsData);
|
|
|
|
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) => {
|
|
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);
|
|
};
|
|
|
|
const openDetailsModal = (asset: Asset) => {
|
|
setActiveAsset(asset);
|
|
setIsDetailsOpen(true);
|
|
};
|
|
|
|
const handleDeleteAsset = (id: string) => {
|
|
const asset = assets.find(a => a.id === id);
|
|
if (asset) {
|
|
setAssetToDelete({ id: asset.id, title: asset.title });
|
|
}
|
|
};
|
|
|
|
const confirmDeleteAsset = async () => {
|
|
if (!assetToDelete) return;
|
|
try {
|
|
await deleteAsset(assetToDelete.id);
|
|
success("Asset deleted successfully", `"${assetToDelete.title}" has been permanently removed.`);
|
|
setAssetToDelete(null);
|
|
await fetchData();
|
|
} catch (err: any) {
|
|
console.error("Failed to delete asset", err);
|
|
error("Failed to delete asset", err.response?.data?.error || "Something went wrong.");
|
|
}
|
|
};
|
|
|
|
const handleRequestDownload = async (asset: Asset) => {
|
|
try {
|
|
await requestDownload(asset.id);
|
|
success("Download request submitted", "An administrator has been notified of your request.");
|
|
await fetchData();
|
|
} catch (err: any) {
|
|
console.error("Failed to request download access", err);
|
|
error("Failed to submit request", err.response?.data?.error || "Something went wrong.");
|
|
}
|
|
};
|
|
|
|
const handleApproveRequest = async (assetId: string, requestId: string) => {
|
|
try {
|
|
await approveDownloadRequest(assetId, requestId);
|
|
success("Download request approved", "The partner can now download this asset.");
|
|
await fetchData();
|
|
} catch (err: any) {
|
|
console.error("Failed to approve request", err);
|
|
error("Failed to approve request", err.response?.data?.error || "Something went wrong.");
|
|
}
|
|
};
|
|
|
|
const handleRejectRequest = async (assetId: string, requestId: string) => {
|
|
try {
|
|
await rejectDownloadRequest(assetId, requestId);
|
|
success("Download request rejected", "The access request was denied.");
|
|
await fetchData();
|
|
} catch (err: any) {
|
|
console.error("Failed to reject request", err);
|
|
error("Failed to reject request", err.response?.data?.error || "Something went wrong.");
|
|
}
|
|
};
|
|
|
|
const handleDownload = async (asset: Asset) => {
|
|
success("Download started", `Downloading "${asset.title}"...`);
|
|
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: any) {
|
|
console.error("Failed to process download", err);
|
|
error("Download failed", err.response?.data?.error || "Could not retrieve asset file.");
|
|
}
|
|
};
|
|
|
|
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" ||
|
|
(selectedCategory === "RECOMMENDED" &&
|
|
recommendedAssets.some((r) => r.id === asset.id));
|
|
|
|
return matchesSearch && matchesCategory;
|
|
});
|
|
|
|
const pendingRequestsCount = assets.reduce((acc, asset) => {
|
|
return (
|
|
acc +
|
|
(asset.downloadRequests?.filter((r) => r.status === "PENDING").length ||
|
|
0)
|
|
);
|
|
}, 0);
|
|
|
|
// Header component
|
|
const headerNode = (
|
|
<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>
|
|
}
|
|
/>
|
|
);
|
|
|
|
// Toolbar component
|
|
const toolbarNode = (
|
|
<div className="flex flex-col md:flex-row gap-4 items-center justify-between p-3.5 bg-ink-0 border border-ink-200 rounded-xl shadow-sm">
|
|
<div className="flex flex-col md:flex-row items-stretch md:items-center gap-3 flex-1 min-w-0 w-full">
|
|
{/* Search Bar with stable minimum width */}
|
|
<div className="relative w-full md:w-[280px] shrink-0 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-50 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 hover:border-ink-300 font-sans"
|
|
placeholder="Search by title, desc, tag, category..."
|
|
/>
|
|
</div>
|
|
|
|
{/* Toggle Controls: All Assets vs Recommended */}
|
|
{user?.role === "PARTNER_USER" && recommendedAssets.length > 0 && (
|
|
<div className="flex items-center gap-1 bg-ink-50/50 p-1 rounded-xl border border-ink-200 shadow-sm shrink-0">
|
|
<button
|
|
onClick={() => setSelectedCategory("ALL")}
|
|
className={`px-3.5 py-1.5 rounded-lg text-[10px] uppercase tracking-wider font-extrabold transition-all cursor-pointer whitespace-nowrap ${
|
|
selectedCategory === "ALL"
|
|
? "bg-ink-900 text-ink-0 shadow-sm"
|
|
: "text-ink-600 hover:text-ink-900 hover:bg-ink-100/50"
|
|
}`}
|
|
>
|
|
All Assets
|
|
</button>
|
|
<button
|
|
onClick={() => setSelectedCategory("RECOMMENDED")}
|
|
className={`px-3.5 py-1.5 rounded-lg text-[10px] uppercase tracking-wider font-extrabold transition-all cursor-pointer whitespace-nowrap flex items-center gap-1.5 ${
|
|
selectedCategory === "RECOMMENDED"
|
|
? "bg-gradient-to-r from-amber-500 to-amber-600 text-zinc-950 shadow-md shadow-amber-500/25"
|
|
: "text-amber-700 dark:text-amber-400 hover:bg-amber-500/10"
|
|
}`}
|
|
>
|
|
<Sparkles className="w-3.5 h-3.5 animate-pulse" />
|
|
<span>Recommended for You</span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 shrink-0 w-full md:w-auto justify-end">
|
|
{user?.role === "ADMIN" && pendingRequestsCount > 0 && (
|
|
<Button
|
|
onClick={() => setIsRequestsOpen(true)}
|
|
variant="secondary"
|
|
size="sm"
|
|
icon={
|
|
<span className="w-4 h-4 rounded-full bg-ink-900 text-ink-0 text-[10px] flex items-center justify-center font-extrabold mr-0.5">
|
|
{pendingRequestsCount}
|
|
</span>
|
|
}
|
|
>
|
|
Download Requests
|
|
</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" && (
|
|
<Button
|
|
onClick={() => setIsGroupsOpen(true)}
|
|
variant="secondary"
|
|
size="sm"
|
|
icon={<Folder className="w-3.5 h-3.5" />}
|
|
>
|
|
Manage Groups
|
|
</Button>
|
|
)}
|
|
|
|
{user?.role === "ADMIN" && (
|
|
<Button
|
|
onClick={() => setIsUploadOpen(true)}
|
|
variant="primary"
|
|
size="sm"
|
|
icon={<UploadCloud className="w-3.5 h-3.5" />}
|
|
>
|
|
Create / Upload Asset
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<PageLayout header={headerNode} toolbar={toolbarNode}>
|
|
<div className="p-5 flex-1 min-h-0 overflow-y-auto">
|
|
{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>
|
|
) : (
|
|
<>
|
|
{selectedCategory === "RECOMMENDED" && (
|
|
<div className="mb-8 p-8 bg-gradient-to-br from-amber-500/[0.07] via-slate-900/40 to-slate-950/20 border border-amber-500/20 rounded-2xl relative overflow-hidden shadow-lg backdrop-blur-md">
|
|
{/* Background decorative elements */}
|
|
<div className="absolute top-0 right-0 w-80 h-80 bg-amber-500/[0.04] rounded-full blur-3xl -mr-16 -mt-16 pointer-events-none" />
|
|
<div className="absolute -left-10 -bottom-10 w-60 h-60 bg-amber-600/[0.02] rounded-full blur-3xl pointer-events-none" />
|
|
|
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6 relative z-10">
|
|
<div className="space-y-2">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-amber-500/10 border border-amber-500/20 text-xs font-bold text-amber-700 dark:text-amber-400 uppercase tracking-wider">
|
|
<Sparkles className="w-3.5 h-3.5 animate-pulse" />
|
|
<span>Curated Catalog</span>
|
|
</span>
|
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-md bg-ink-900/60 text-ink-200 text-[10px] font-bold uppercase tracking-wider border border-ink-700/30">
|
|
{recommendedAssets.length} {recommendedAssets.length === 1 ? 'Asset' : 'Assets'}
|
|
</span>
|
|
</div>
|
|
<h2 className="text-3xl font-extrabold text-ink-900 font-sans tracking-tight">Recommended for You</h2>
|
|
<p className="text-ink-600 text-sm max-w-2xl leading-relaxed">
|
|
These resources have been hand-picked by our team to match your partner profile and accelerate your integration.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</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={containerVariants}
|
|
initial="hidden"
|
|
animate="show"
|
|
className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4 items-start"
|
|
>
|
|
{filteredAssets.map((asset) => (
|
|
<motion.div key={asset.id} variants={itemVariants} className="relative h-[410px] w-full flex flex-col">
|
|
<AssetCard
|
|
asset={asset}
|
|
user={user}
|
|
activeMenuId={activeMenuId}
|
|
setActiveMenuId={setActiveMenuId}
|
|
onViewDetails={openDetailsModal}
|
|
onEdit={openEditModal}
|
|
onShare={openShareModal}
|
|
onDelete={handleDeleteAsset}
|
|
onOpenViewer={openViewerModal}
|
|
onDownload={handleDownload}
|
|
onRequestDownload={handleRequestDownload}
|
|
isSelected={selectedAssetIds.includes(asset.id)}
|
|
onToggleSelect={handleToggleSelectAsset}
|
|
isExpanded={expandedAssetId === asset.id}
|
|
onToggleExpand={() => setExpandedAssetId(expandedAssetId === asset.id ? null : asset.id)}
|
|
isRecommended={recommendedAssets.some(r => r.id === asset.id)}
|
|
/>
|
|
</motion.div>
|
|
))}
|
|
</motion.div>
|
|
)}
|
|
</>
|
|
)}
|
|
</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);
|
|
setSelectedAssetIds([]);
|
|
}}
|
|
asset={activeAsset}
|
|
assetIds={selectedAssetIds}
|
|
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}
|
|
/>
|
|
|
|
<ManageGroupsModal
|
|
isOpen={isGroupsOpen}
|
|
onClose={() => setIsGroupsOpen(false)}
|
|
assets={assets}
|
|
groups={groups}
|
|
onRefresh={fetchData}
|
|
/>
|
|
|
|
{/* Delete Confirmation Modal */}
|
|
<Modal
|
|
isOpen={assetToDelete !== null}
|
|
onClose={() => setAssetToDelete(null)}
|
|
title="Delete Asset"
|
|
subtitle="Confirm permanent resource deletion."
|
|
size="sm"
|
|
>
|
|
<div className="space-y-4 font-sans">
|
|
<p className="text-xs text-ink-600 leading-relaxed">
|
|
Are you sure you want to permanently delete <span className="font-bold text-ink-900">"{assetToDelete?.title}"</span>? This action cannot be undone.
|
|
</p>
|
|
<div className="flex gap-3 justify-end pt-2">
|
|
<Button
|
|
onClick={() => setAssetToDelete(null)}
|
|
variant="secondary"
|
|
size="sm"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={confirmDeleteAsset}
|
|
variant="danger"
|
|
size="sm"
|
|
>
|
|
Delete Permanently
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
</PageLayout>
|
|
);
|
|
};
|
|
|
|
export default AssetsPage;
|