1642 lines
77 KiB
TypeScript
1642 lines
77 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import type { Variants } from 'framer-motion';
|
|
import {
|
|
UploadCloud, Search, FileText, Image as ImageIcon,
|
|
File, MoreVertical, Download, CheckCircle,
|
|
X, Trash2, Share2, Eye, Lock, Clock, Check, AlertCircle,
|
|
ChevronDown, ChevronUp, Edit3, Globe, ExternalLink, Maximize2, Minimize2
|
|
} from 'lucide-react';
|
|
import { useAuthStore } from '../hooks/use-auth';
|
|
import { axiosInstance } from '../services/axios';
|
|
|
|
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 } }
|
|
};
|
|
|
|
interface SharedWithOrg {
|
|
organizationId: string;
|
|
userId: string | null;
|
|
organization: {
|
|
id: string;
|
|
name: string;
|
|
};
|
|
user?: {
|
|
id: string;
|
|
email: string;
|
|
} | null;
|
|
}
|
|
|
|
interface DownloadRequest {
|
|
id: string;
|
|
assetId: string;
|
|
userId: string;
|
|
status: string;
|
|
createdAt: string;
|
|
user?: {
|
|
id: string;
|
|
email: string;
|
|
};
|
|
asset?: {
|
|
title: string;
|
|
};
|
|
}
|
|
|
|
interface Asset {
|
|
id: string;
|
|
title: string;
|
|
type: string;
|
|
size: number;
|
|
url: string;
|
|
version: number;
|
|
uploadedBy: string;
|
|
description: string | null;
|
|
categoryId: string | null;
|
|
subcategory: string | null;
|
|
tags: string[];
|
|
downloadsCount: number;
|
|
githubUrl: string | null;
|
|
status: string;
|
|
isDownloadable: boolean;
|
|
createdAt: string;
|
|
sharedWith?: SharedWithOrg[];
|
|
downloadRequests?: DownloadRequest[];
|
|
}
|
|
|
|
interface OrgUser {
|
|
id: string;
|
|
email: string;
|
|
role: string;
|
|
}
|
|
|
|
interface Organization {
|
|
id: string;
|
|
name: string;
|
|
status: string;
|
|
users?: OrgUser[];
|
|
}
|
|
|
|
interface ShareItem {
|
|
organizationId: string;
|
|
userId: string | null;
|
|
}
|
|
|
|
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');
|
|
|
|
// Modals & Popovers
|
|
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);
|
|
const [isMaximized, setIsMaximized] = useState(false);
|
|
const [textPreviewContent, setTextPreviewContent] = useState('');
|
|
const [isLoadingText, setIsLoadingText] = useState(false);
|
|
const [expandedOrgId, setExpandedOrgId] = useState<string | null>(null);
|
|
|
|
// Upload Form
|
|
const [uploadTab, setUploadTab] = useState<'file' | 'url'>('file');
|
|
const [uploadFile, setUploadFile] = useState<File | null>(null);
|
|
const [uploadUrl, setUploadUrl] = useState('');
|
|
const [uploadTitle, setUploadTitle] = useState('');
|
|
const [uploadDescription, setUploadDescription] = useState('');
|
|
const [uploadCategory, setUploadCategory] = useState('Marketing');
|
|
const [uploadSubcategory, setUploadSubcategory] = useState('');
|
|
const [uploadTags, setUploadTags] = useState('');
|
|
const [uploadGithubUrl, setUploadGithubUrl] = useState('');
|
|
const [uploadIsDownloadable, setUploadIsDownloadable] = useState(true);
|
|
const [isUploading, setIsUploading] = useState(false);
|
|
|
|
// Edit Form
|
|
const [editTitle, setEditTitle] = useState('');
|
|
const [editDescription, setEditDescription] = useState('');
|
|
const [editCategory, setEditCategory] = useState('Marketing');
|
|
const [editSubcategory, setEditSubcategory] = useState('');
|
|
const [editTags, setEditTags] = useState('');
|
|
const [editGithubUrl, setEditGithubUrl] = useState('');
|
|
const [editIsDownloadable, setEditIsDownloadable] = useState(true);
|
|
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
|
|
|
// Share Form state
|
|
const [sharesList, setSharesList] = useState<ShareItem[]>([]);
|
|
const [isSavingShare, setIsSavingShare] = useState(false);
|
|
|
|
useEffect(() => {
|
|
fetchData();
|
|
}, []);
|
|
|
|
const fetchData = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const assetsRes = await axiosInstance.get('/assets');
|
|
setAssets(assetsRes.data);
|
|
|
|
if (user?.role === 'ADMIN') {
|
|
const orgsRes = await axiosInstance.get('/organizations');
|
|
setOrganizations(orgsRes.data);
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to fetch assets data', err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const isLocalUrl = (url: string) => {
|
|
return url.includes('localhost') || url.includes('127.0.0.1');
|
|
};
|
|
|
|
const getGithubRawUrl = (url: string) => {
|
|
let raw = url.trim();
|
|
if (raw.endsWith('.git')) {
|
|
raw = raw.slice(0, -4);
|
|
}
|
|
if (raw.endsWith('/')) {
|
|
raw = raw.slice(0, -1);
|
|
}
|
|
|
|
if (raw.includes('/blob/')) {
|
|
return raw.replace('github.com', 'raw.githubusercontent.com').replace('/blob/', '/');
|
|
}
|
|
|
|
const match = raw.match(/github\.com\/([^/]+)\/([^/]+)/);
|
|
if (match && match[1] && match[2]) {
|
|
const owner = match[1];
|
|
const repo = match[2];
|
|
return {
|
|
owner,
|
|
repo,
|
|
mainUrl: `https://raw.githubusercontent.com/${owner}/${repo}/main/README.md`,
|
|
masterUrl: `https://raw.githubusercontent.com/${owner}/${repo}/master/README.md`,
|
|
};
|
|
}
|
|
return null;
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (isViewerOpen && activeAsset) {
|
|
setTextPreviewContent('');
|
|
setIsLoadingText(false);
|
|
|
|
const assetUrl = activeAsset.url.toLowerCase();
|
|
|
|
if (
|
|
activeAsset.type !== 'url' &&
|
|
(assetUrl.endsWith('.md') ||
|
|
assetUrl.endsWith('.txt') ||
|
|
activeAsset.type.includes('text') ||
|
|
activeAsset.type.includes('markdown'))
|
|
) {
|
|
setIsLoadingText(true);
|
|
axiosInstance.get(getFullAssetUrl(activeAsset.url), { responseType: 'text' })
|
|
.then(res => {
|
|
setTextPreviewContent(res.data);
|
|
setIsLoadingText(false);
|
|
})
|
|
.catch(err => {
|
|
console.error(err);
|
|
setTextPreviewContent('Failed to load text preview.');
|
|
setIsLoadingText(false);
|
|
});
|
|
}
|
|
else if (activeAsset.type === 'url' && activeAsset.url.includes('github.com')) {
|
|
const ghInfo = getGithubRawUrl(activeAsset.url);
|
|
if (ghInfo) {
|
|
setIsLoadingText(true);
|
|
const targetUrl = typeof ghInfo === 'string' ? ghInfo : ghInfo.mainUrl;
|
|
|
|
fetch(targetUrl)
|
|
.then(async (res) => {
|
|
if (!res.ok && typeof ghInfo !== 'string') {
|
|
const fallbackRes = await fetch(ghInfo.masterUrl);
|
|
if (fallbackRes.ok) {
|
|
return fallbackRes.text();
|
|
}
|
|
throw new Error('README not found');
|
|
}
|
|
return res.text();
|
|
})
|
|
.then(data => {
|
|
setTextPreviewContent(data);
|
|
setIsLoadingText(false);
|
|
})
|
|
.catch(err => {
|
|
console.error(err);
|
|
setTextPreviewContent(`### GitHub Repository: ${typeof ghInfo === 'string' ? activeAsset.url : `${ghInfo.owner}/${ghInfo.repo}`}\n\nCould not fetch README.md automatically. The repository might be private or not contain a README.md file.`);
|
|
setIsLoadingText(false);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}, [isViewerOpen, activeAsset]);
|
|
|
|
const handleUploadSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (uploadTab === 'file' && !uploadFile) return;
|
|
if (uploadTab === 'url' && !uploadUrl) return;
|
|
|
|
setIsUploading(true);
|
|
const formData = new FormData();
|
|
if (uploadTab === 'file' && uploadFile) {
|
|
formData.append('file', uploadFile);
|
|
} else {
|
|
formData.append('isUrlAsset', 'true');
|
|
formData.append('url', uploadUrl);
|
|
}
|
|
formData.append('title', uploadTitle || (uploadFile ? uploadFile.name : uploadUrl));
|
|
formData.append('description', uploadDescription);
|
|
formData.append('categoryId', uploadCategory);
|
|
formData.append('subcategory', uploadSubcategory);
|
|
formData.append('tags', JSON.stringify(uploadTags.split(',').map(t => t.trim()).filter(Boolean)));
|
|
formData.append('githubUrl', uploadGithubUrl);
|
|
formData.append('isDownloadable', String(uploadIsDownloadable));
|
|
|
|
try {
|
|
await axiosInstance.post('/assets/upload', formData, {
|
|
headers: { 'Content-Type': 'multipart/form-data' }
|
|
});
|
|
setUploadFile(null);
|
|
setUploadUrl('');
|
|
setUploadTitle('');
|
|
setUploadDescription('');
|
|
setUploadCategory('Marketing');
|
|
setUploadSubcategory('');
|
|
setUploadTags('');
|
|
setUploadGithubUrl('');
|
|
setUploadIsDownloadable(true);
|
|
setIsUploadOpen(false);
|
|
await fetchData();
|
|
} catch (err) {
|
|
console.error('Failed to upload asset', err);
|
|
} finally {
|
|
setIsUploading(false);
|
|
}
|
|
};
|
|
|
|
const handleEditSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!activeAsset) return;
|
|
|
|
setIsSavingEdit(true);
|
|
try {
|
|
await axiosInstance.patch(`/assets/${activeAsset.id}`, {
|
|
title: editTitle,
|
|
description: editDescription,
|
|
categoryId: editCategory,
|
|
subcategory: editSubcategory,
|
|
tags: editTags.split(',').map(t => t.trim()).filter(Boolean),
|
|
githubUrl: editGithubUrl,
|
|
isDownloadable: editIsDownloadable,
|
|
});
|
|
setIsEditOpen(false);
|
|
setActiveAsset(null);
|
|
await fetchData();
|
|
} catch (err) {
|
|
console.error('Failed to save asset details', err);
|
|
} finally {
|
|
setIsSavingEdit(false);
|
|
}
|
|
};
|
|
|
|
const openEditModal = (asset: Asset) => {
|
|
setActiveAsset(asset);
|
|
setEditTitle(asset.title);
|
|
setEditDescription(asset.description || '');
|
|
setEditCategory(asset.categoryId || 'Marketing');
|
|
setEditSubcategory(asset.subcategory || '');
|
|
setEditTags(asset.tags.join(', '));
|
|
setEditGithubUrl(asset.githubUrl || '');
|
|
setEditIsDownloadable(asset.isDownloadable);
|
|
setIsEditOpen(true);
|
|
setActiveMenuId(null);
|
|
};
|
|
|
|
const handleShareSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!activeAsset) return;
|
|
|
|
setIsSavingShare(true);
|
|
try {
|
|
await axiosInstance.patch(`/assets/${activeAsset.id}`, {
|
|
shares: sharesList
|
|
});
|
|
setIsShareOpen(false);
|
|
setActiveAsset(null);
|
|
await fetchData();
|
|
} catch (err) {
|
|
console.error('Failed to update share permissions', err);
|
|
} finally {
|
|
setIsSavingShare(false);
|
|
}
|
|
};
|
|
|
|
const openShareModal = (asset: Asset) => {
|
|
setActiveAsset(asset);
|
|
setSharesList(
|
|
asset.sharedWith?.map(s => ({
|
|
organizationId: s.organizationId,
|
|
userId: s.userId
|
|
})) || []
|
|
);
|
|
setIsShareOpen(true);
|
|
setActiveMenuId(null);
|
|
};
|
|
|
|
const openViewerModal = (asset: Asset) => {
|
|
setActiveAsset(asset);
|
|
setIsViewerOpen(true);
|
|
setActiveMenuId(null);
|
|
};
|
|
|
|
const openDetailsModal = (asset: Asset) => {
|
|
setActiveAsset(asset);
|
|
setIsDetailsOpen(true);
|
|
setActiveMenuId(null);
|
|
};
|
|
|
|
const handleDeleteAsset = async (id: string) => {
|
|
if (!window.confirm('Are you sure you want to permanently delete this asset?')) return;
|
|
try {
|
|
await axiosInstance.delete(`/assets/${id}`);
|
|
setActiveMenuId(null);
|
|
await fetchData();
|
|
} catch (err) {
|
|
console.error('Failed to delete asset', err);
|
|
}
|
|
};
|
|
|
|
const handleRequestDownload = async (asset: Asset) => {
|
|
try {
|
|
await axiosInstance.post(`/assets/${asset.id}/request-download`);
|
|
await fetchData();
|
|
} catch (err) {
|
|
console.error('Failed to request download access', err);
|
|
}
|
|
};
|
|
|
|
const handleApproveRequest = async (assetId: string, requestId: string) => {
|
|
try {
|
|
await axiosInstance.post(`/assets/${assetId}/approve-download/${requestId}`);
|
|
await fetchData();
|
|
} catch (err) {
|
|
console.error('Failed to approve request', err);
|
|
}
|
|
};
|
|
|
|
const handleRejectRequest = async (assetId: string, requestId: string) => {
|
|
try {
|
|
await axiosInstance.post(`/assets/${assetId}/reject-download/${requestId}`);
|
|
await fetchData();
|
|
} catch (err) {
|
|
console.error('Failed to reject request', err);
|
|
}
|
|
};
|
|
|
|
const handleDownload = async (asset: Asset) => {
|
|
try {
|
|
await axiosInstance.post(`/assets/${asset.id}/download`);
|
|
|
|
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 formatBytes = (bytes: number, decimals = 2) => {
|
|
if (bytes === 0) return '0 Bytes';
|
|
const k = 1024;
|
|
const dm = decimals < 0 ? 0 : decimals;
|
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
|
};
|
|
|
|
const getAssetIcon = (type: string) => {
|
|
if (type === 'url') return Globe;
|
|
if (type.includes('pdf')) return FileText;
|
|
if (type.includes('image') || type.includes('png') || type.includes('jpg')) return ImageIcon;
|
|
return File;
|
|
};
|
|
|
|
const isRenderable = (type: string) => {
|
|
return type === 'url' || type.includes('pdf') || type.includes('image') || type.includes('png') || type.includes('jpg');
|
|
};
|
|
|
|
const getFullAssetUrl = (url: string) => {
|
|
return url.startsWith('http')
|
|
? url
|
|
: `${axiosInstance.defaults.baseURL?.replace('/api/v1', '')}${url}`;
|
|
};
|
|
|
|
const CATEGORIES = ['ALL', 'Marketing', 'Presentations', 'Branding', 'Resources', 'Technical'];
|
|
|
|
// Improved search considering all attributes
|
|
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;
|
|
});
|
|
|
|
// Calculate pending download requests count
|
|
const pendingRequestsCount = assets.reduce((acc, asset) => {
|
|
return acc + (asset.downloadRequests?.filter(r => r.status === 'PENDING').length || 0);
|
|
}, 0);
|
|
|
|
// Helper sharing logic
|
|
const isOrgSharedEntirely = (orgId: string) => {
|
|
return sharesList.some(s => s.organizationId === orgId && s.userId === null);
|
|
};
|
|
|
|
const isUserSharedSpecifically = (orgId: string, userId: string) => {
|
|
return sharesList.some(s => s.organizationId === orgId && s.userId === userId);
|
|
};
|
|
|
|
const handleToggleOrg = (orgId: string) => {
|
|
const isShared = isOrgSharedEntirely(orgId);
|
|
if (isShared) {
|
|
// Remove org and its users
|
|
setSharesList(prev => prev.filter(s => s.organizationId !== orgId));
|
|
} else {
|
|
// Remove any specific users from this org first, then add org-wide share
|
|
setSharesList(prev => [
|
|
...prev.filter(s => s.organizationId !== orgId),
|
|
{ organizationId: orgId, userId: null }
|
|
]);
|
|
}
|
|
};
|
|
|
|
const handleToggleUser = (orgId: string, userId: string) => {
|
|
const isShared = isUserSharedSpecifically(orgId, userId);
|
|
if (isShared) {
|
|
setSharesList(prev => prev.filter(s => !(s.organizationId === orgId && s.userId === userId)));
|
|
} else {
|
|
// Remove org-wide share first if it exists
|
|
setSharesList(prev => [
|
|
...prev.filter(s => !(s.organizationId === orgId && s.userId === null)),
|
|
{ organizationId: orgId, userId }
|
|
]);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<motion.div variants={containerVariants} initial="hidden" animate="show" className="w-full space-y-8 text-ink-900 animate-fade-in">
|
|
|
|
{/* Header Section */}
|
|
<motion.div variants={itemVariants} className="flex flex-col lg:flex-row lg:items-end justify-between gap-6">
|
|
<div className="flex flex-col gap-3">
|
|
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-ink-100 border border-ink-200 w-fit">
|
|
<CheckCircle className="w-3.5 h-3.5 text-ink-950" />
|
|
<span className="text-[10px] font-bold text-ink-900 tracking-widest uppercase">Global CDN Active</span>
|
|
</div>
|
|
<h1 className="text-4xl md:text-5xl font-extrabold text-ink-900 tracking-tight mt-2">
|
|
Asset Library
|
|
</h1>
|
|
<p className="text-base text-ink-500 max-w-2xl font-medium mt-1">
|
|
Securely manage, distribute, and track marketing collateral and partner resources.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
{user?.role === 'ADMIN' && pendingRequestsCount > 0 && (
|
|
<button
|
|
onClick={() => setIsRequestsOpen(true)}
|
|
className="bg-ink-100 text-ink-900 border border-ink-300 font-bold tracking-wide py-3.5 px-6 rounded-xl hover:bg-ink-200 transition-all flex items-center justify-center gap-2 relative shadow-sm"
|
|
>
|
|
<span>Download Requests</span>
|
|
<span className="w-5 h-5 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 tracking-wide py-3.5 px-6 rounded-xl hover:bg-ink-800 transition-all duration-300 overflow-hidden flex items-center justify-center gap-2 shadow-sm hover:-translate-y-0.5"
|
|
>
|
|
<UploadCloud className="w-4 h-4 group-hover:-translate-y-0.5 transition-transform" />
|
|
<span>Create / Upload Asset</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
|
|
{/* Action Bar */}
|
|
<motion.div variants={itemVariants} className="flex flex-col md:flex-row gap-4 items-center pt-4">
|
|
<div className="relative flex-1 w-full group">
|
|
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
|
<Search className="w-5 h-5 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-xl py-3.5 pl-11 pr-4 text-ink-900 placeholder-ink-400 outline-none transition-all focus:border-ink-900/50 focus:ring-4 ring-ink-900/10 font-medium 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-4 py-2.5 rounded-xl border text-xs font-bold tracking-wide transition-all whitespace-nowrap shadow-sm ${
|
|
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-20 text-center bg-ink-0 border border-ink-200 rounded-3xl">
|
|
<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-6 pt-2">
|
|
{filteredAssets.map((asset) => {
|
|
const Icon = getAssetIcon(asset.type);
|
|
const isMenuOpen = activeMenuId === asset.id;
|
|
const canDirectDownload = user?.role === 'ADMIN' || asset.isDownloadable || asset.downloadRequests?.[0]?.status === 'APPROVED';
|
|
const requestStatus = asset.downloadRequests?.[0]?.status;
|
|
|
|
return (
|
|
<div key={asset.id} className="group relative bg-ink-0 border border-ink-200 rounded-2xl p-5 hover:border-ink-300 transition-all duration-300 shadow-sm hover:shadow-md hover:-translate-y-1 flex flex-col justify-between">
|
|
|
|
<div>
|
|
<div className="flex justify-between items-start mb-5 relative">
|
|
<div className="w-12 h-12 rounded-xl flex items-center justify-center text-ink-900 bg-ink-100 border border-ink-200">
|
|
<Icon className="w-6 h-6" />
|
|
</div>
|
|
|
|
<div className="relative flex items-center gap-1.5">
|
|
{isRenderable(asset.type) && (
|
|
<button
|
|
onClick={() => openViewerModal(asset)}
|
|
className="p-1.5 rounded-lg text-ink-500 hover:text-ink-900 hover:bg-ink-100 transition-colors"
|
|
title="Preview Online"
|
|
>
|
|
<Eye className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
|
|
<button
|
|
onClick={() => setActiveMenuId(isMenuOpen ? null : asset.id)}
|
|
className="p-1.5 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-100 transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100"
|
|
>
|
|
<MoreVertical className="w-4 h-4" />
|
|
</button>
|
|
|
|
{isMenuOpen && (
|
|
<>
|
|
<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-xl shadow-lg z-20 overflow-hidden py-1">
|
|
<button
|
|
onClick={() => openDetailsModal(asset)}
|
|
className="w-full text-left px-4 py-2 text-xs font-semibold text-ink-700 hover:bg-ink-50 hover:text-ink-900 flex items-center gap-2"
|
|
>
|
|
<File className="w-3.5 h-3.5" />
|
|
View Details
|
|
</button>
|
|
{user?.role === 'ADMIN' && (
|
|
<>
|
|
<button
|
|
onClick={() => openEditModal(asset)}
|
|
className="w-full text-left px-4 py-2 text-xs font-semibold text-ink-700 hover:bg-ink-50 hover:text-ink-900 flex items-center gap-2"
|
|
>
|
|
<Edit3 className="w-3.5 h-3.5" />
|
|
Edit Asset
|
|
</button>
|
|
<button
|
|
onClick={() => openShareModal(asset)}
|
|
className="w-full text-left px-4 py-2 text-xs font-semibold text-ink-700 hover:bg-ink-50 hover:text-ink-900 flex items-center gap-2"
|
|
>
|
|
<Share2 className="w-3.5 h-3.5" />
|
|
Share Settings
|
|
</button>
|
|
<button
|
|
onClick={() => handleDeleteAsset(asset.id)}
|
|
className="w-full text-left px-4 py-2 text-xs font-semibold text-red-650 hover:bg-red-500/10 flex items-center gap-2"
|
|
>
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
Delete Asset
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-1 mb-6">
|
|
<div className="flex items-center gap-1.5 mb-2">
|
|
<div className="inline-block px-2 py-0.5 rounded text-[9px] font-bold text-ink-500 uppercase tracking-widest bg-ink-50 border border-ink-200">
|
|
{asset.categoryId || 'General'}
|
|
</div>
|
|
{!asset.isDownloadable && (
|
|
<div className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[9px] font-bold text-ink-600 bg-ink-100 border border-ink-200">
|
|
<Lock className="w-2.5 h-2.5" />
|
|
<span>Strict View Only</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<h3 className="font-bold text-ink-900 text-base leading-snug line-clamp-2 group-hover:text-ink-950 transition-colors" title={asset.title}>
|
|
{asset.title}
|
|
</h3>
|
|
<p className="text-xs font-medium text-ink-400 mt-1">
|
|
{asset.type === 'url' ? 'External Link' : formatBytes(asset.size)} • {new Date(asset.createdAt).toLocaleDateString()}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="pt-4 border-t border-ink-100 flex items-center justify-between mt-auto">
|
|
<div className="flex flex-col">
|
|
<span className="text-[10px] font-bold uppercase tracking-wider text-ink-400">
|
|
{asset.type === 'url' ? 'Type' : 'Downloads'}
|
|
</span>
|
|
<span className="text-xs font-extrabold text-ink-900 mt-0.5">
|
|
{asset.type === 'url' ? 'URL Link' : asset.downloadsCount}
|
|
</span>
|
|
</div>
|
|
|
|
{asset.type === 'url' ? (
|
|
<a
|
|
href={asset.url}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="w-8 h-8 rounded-lg bg-ink-900 border border-ink-900 flex items-center justify-center text-ink-0 hover:bg-ink-800 transition-all shadow-sm"
|
|
title="Open External URL"
|
|
>
|
|
<ExternalLink className="w-4 h-4" />
|
|
</a>
|
|
) : canDirectDownload ? (
|
|
<button
|
|
onClick={() => handleDownload(asset)}
|
|
className="w-8 h-8 rounded-lg bg-ink-50 border border-ink-200 flex items-center justify-center text-ink-600 hover:bg-ink-100 hover:border-ink-300 hover:text-ink-900 transition-all shadow-sm"
|
|
title="Download Asset"
|
|
>
|
|
<Download className="w-4 h-4" />
|
|
</button>
|
|
) : requestStatus === 'PENDING' ? (
|
|
<div className="inline-flex items-center gap-1 text-[11px] font-bold text-ink-500 bg-ink-50 border border-ink-200 px-3 py-1.5 rounded-lg">
|
|
<Clock className="w-3.5 h-3.5 text-ink-500 animate-pulse" />
|
|
<span>Pending Access</span>
|
|
</div>
|
|
) : requestStatus === 'REJECTED' ? (
|
|
<button
|
|
onClick={() => handleRequestDownload(asset)}
|
|
className="inline-flex items-center gap-1 text-[11px] font-bold text-red-650 bg-red-500/10 border border-red-500/20 px-3 py-1.5 rounded-lg hover:bg-red-500/25 transition-all"
|
|
>
|
|
<AlertCircle className="w-3.5 h-3.5" />
|
|
<span>Rejected (Retry)</span>
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={() => handleRequestDownload(asset)}
|
|
className="inline-flex items-center gap-1.5 text-[11px] font-bold text-ink-900 bg-ink-100 border border-ink-300 hover:bg-ink-200 px-3 py-1.5 rounded-lg transition-all"
|
|
>
|
|
<Lock className="w-3.5 h-3.5" />
|
|
<span>Request Download</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</motion.div>
|
|
)}
|
|
|
|
{/* Upload Asset Modal */}
|
|
<AnimatePresence>
|
|
{isUploadOpen && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
onClick={() => setIsUploadOpen(false)}
|
|
className="absolute inset-0 bg-ink-950/40 backdrop-blur-sm"
|
|
/>
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
|
className="relative bg-ink-0 border border-ink-200 rounded-3xl p-8 max-w-lg w-full max-h-[90vh] overflow-y-auto shadow-2xl z-10 space-y-6"
|
|
>
|
|
<div className="flex justify-between items-center pb-4 border-b border-ink-100">
|
|
<h3 className="text-xl font-bold text-ink-900">Upload / Link Asset</h3>
|
|
<button
|
|
onClick={() => setIsUploadOpen(false)}
|
|
className="p-1 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-colors"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Toggle upload tabs */}
|
|
<div className="flex bg-ink-50 p-1.5 rounded-xl border border-ink-200">
|
|
<button
|
|
type="button"
|
|
onClick={() => setUploadTab('file')}
|
|
className={`flex-1 py-2 text-xs font-bold rounded-lg transition-all ${uploadTab === 'file' ? 'bg-ink-0 text-ink-900 shadow-sm' : 'text-ink-550'}`}
|
|
>
|
|
Secure File Upload
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setUploadTab('url')}
|
|
className={`flex-1 py-2 text-xs font-bold rounded-lg transition-all ${uploadTab === 'url' ? 'bg-ink-0 text-ink-900 shadow-sm' : 'text-ink-550'}`}
|
|
>
|
|
External Web URL
|
|
</button>
|
|
</div>
|
|
|
|
<form onSubmit={handleUploadSubmit} className="space-y-4">
|
|
{uploadTab === 'file' ? (
|
|
<div className="border-2 border-dashed border-ink-200 hover:border-ink-400 rounded-2xl p-6 text-center cursor-pointer transition-colors relative bg-ink-50">
|
|
<input
|
|
type="file"
|
|
onChange={(e) => {
|
|
if (e.target.files?.[0]) {
|
|
setUploadFile(e.target.files[0]);
|
|
setUploadTitle(e.target.files[0].name);
|
|
}
|
|
}}
|
|
required={uploadTab === 'file'}
|
|
className="absolute inset-0 opacity-0 cursor-pointer"
|
|
/>
|
|
<UploadCloud className="w-10 h-10 text-ink-400 mx-auto mb-2" />
|
|
<p className="text-xs font-bold text-ink-900">
|
|
{uploadFile ? uploadFile.name : 'Drag & drop or click to upload file'}
|
|
</p>
|
|
<p className="text-[10px] text-ink-450 mt-1">PDF, ZIP, PNG, JPG up to 50MB</p>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
<label className="text-[10px] font-bold uppercase tracking-wider text-ink-500 mb-1.5 block">External Asset URL</label>
|
|
<input
|
|
type="url"
|
|
value={uploadUrl}
|
|
onChange={(e) => setUploadUrl(e.target.value)}
|
|
placeholder="https://example.com/partner-docs"
|
|
required={uploadTab === 'url'}
|
|
className="w-full bg-ink-50 border border-ink-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<label className="text-[10px] font-bold uppercase tracking-wider text-ink-500 mb-1.5 block">Asset Title</label>
|
|
<input
|
|
type="text"
|
|
value={uploadTitle}
|
|
onChange={(e) => setUploadTitle(e.target.value)}
|
|
placeholder="Enter descriptive title"
|
|
className="w-full bg-ink-50 border border-ink-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="text-[10px] font-bold uppercase tracking-wider text-ink-500 mb-1.5 block">Category</label>
|
|
<select
|
|
value={uploadCategory}
|
|
onChange={(e) => setUploadCategory(e.target.value)}
|
|
className="w-full bg-ink-50 border border-ink-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900"
|
|
>
|
|
<option value="Marketing">Marketing</option>
|
|
<option value="Presentations">Presentations</option>
|
|
<option value="Branding">Branding</option>
|
|
<option value="Resources">Resources</option>
|
|
<option value="Technical">Technical</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="text-[10px] font-bold uppercase tracking-wider text-ink-500 mb-1.5 block">Subcategory</label>
|
|
<input
|
|
type="text"
|
|
value={uploadSubcategory}
|
|
onChange={(e) => setUploadSubcategory(e.target.value)}
|
|
placeholder="e.g. Slide Deck"
|
|
className="w-full bg-ink-50 border border-ink-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-[10px] font-bold uppercase tracking-wider text-ink-500 mb-1.5 block">Description</label>
|
|
<textarea
|
|
value={uploadDescription}
|
|
onChange={(e) => setUploadDescription(e.target.value)}
|
|
placeholder="Enter short description about this asset..."
|
|
rows={3}
|
|
className="w-full bg-ink-50 border border-ink-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400 resize-none"
|
|
/>
|
|
</div>
|
|
|
|
{uploadTab === 'file' && (
|
|
<div className="flex items-center gap-3 p-3 bg-ink-50 border border-ink-200 rounded-xl">
|
|
<input
|
|
type="checkbox"
|
|
id="isDownloadable"
|
|
checked={uploadIsDownloadable}
|
|
onChange={(e) => setUploadIsDownloadable(e.target.checked)}
|
|
className="w-4 h-4 rounded border-ink-300 text-ink-900 focus:ring-ink-900/10 cursor-pointer"
|
|
/>
|
|
<div>
|
|
<label htmlFor="isDownloadable" className="text-xs font-bold text-ink-900 cursor-pointer block">
|
|
Allow Direct Download
|
|
</label>
|
|
<span className="text-[10px] text-ink-450">
|
|
If unchecked, clients must request manual download access (Strict View Only).
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<label className="text-[10px] font-bold uppercase tracking-wider text-ink-500 mb-1.5 block">Tags (comma-separated)</label>
|
|
<input
|
|
type="text"
|
|
value={uploadTags}
|
|
onChange={(e) => setUploadTags(e.target.value)}
|
|
placeholder="branding, guideline, pitch"
|
|
className="w-full bg-ink-50 border border-ink-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-[10px] font-bold uppercase tracking-wider text-ink-500 mb-1.5 block">GitHub/Documentation URL (Optional)</label>
|
|
<input
|
|
type="url"
|
|
value={uploadGithubUrl}
|
|
onChange={(e) => setUploadGithubUrl(e.target.value)}
|
|
placeholder="https://github.com/..."
|
|
className="w-full bg-ink-50 border border-ink-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400"
|
|
/>
|
|
</div>
|
|
|
|
<div className="pt-4 border-t border-ink-100 flex justify-end gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsUploadOpen(false)}
|
|
className="px-5 py-3 rounded-xl border border-ink-200 text-ink-700 text-xs font-bold hover:bg-ink-50 transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={isUploading}
|
|
className="px-6 py-3 rounded-xl bg-ink-900 text-ink-0 text-xs font-bold hover:bg-ink-800 transition-colors shadow-sm disabled:opacity-50"
|
|
>
|
|
{isUploading ? 'Publishing...' : 'Publish Asset'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</motion.div>
|
|
</div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* Edit Asset Modal */}
|
|
<AnimatePresence>
|
|
{isEditOpen && activeAsset && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
onClick={() => setIsEditOpen(false)}
|
|
className="absolute inset-0 bg-ink-950/40 backdrop-blur-sm"
|
|
/>
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
|
className="relative bg-ink-0 border border-ink-200 rounded-3xl p-8 max-w-lg w-full max-h-[90vh] overflow-y-auto shadow-2xl z-10 space-y-6"
|
|
>
|
|
<div className="flex justify-between items-center pb-4 border-b border-ink-100">
|
|
<h3 className="text-xl font-bold text-ink-900">Edit Asset Details</h3>
|
|
<button
|
|
onClick={() => setIsEditOpen(false)}
|
|
className="p-1 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-colors"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<form onSubmit={handleEditSubmit} className="space-y-4">
|
|
<div>
|
|
<label className="text-[10px] font-bold uppercase tracking-wider text-ink-500 mb-1.5 block">Asset Title</label>
|
|
<input
|
|
type="text"
|
|
value={editTitle}
|
|
onChange={(e) => setEditTitle(e.target.value)}
|
|
required
|
|
className="w-full bg-ink-50 border border-ink-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="text-[10px] font-bold uppercase tracking-wider text-ink-500 mb-1.5 block">Category</label>
|
|
<select
|
|
value={editCategory}
|
|
onChange={(e) => setEditCategory(e.target.value)}
|
|
className="w-full bg-ink-50 border border-ink-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900"
|
|
>
|
|
<option value="Marketing">Marketing</option>
|
|
<option value="Presentations">Presentations</option>
|
|
<option value="Branding">Branding</option>
|
|
<option value="Resources">Resources</option>
|
|
<option value="Technical">Technical</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="text-[10px] font-bold uppercase tracking-wider text-ink-500 mb-1.5 block">Subcategory</label>
|
|
<input
|
|
type="text"
|
|
value={editSubcategory}
|
|
onChange={(e) => setEditSubcategory(e.target.value)}
|
|
placeholder="e.g. Slide Deck"
|
|
className="w-full bg-ink-50 border border-ink-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-[10px] font-bold uppercase tracking-wider text-ink-500 mb-1.5 block">Description</label>
|
|
<textarea
|
|
value={editDescription}
|
|
onChange={(e) => setEditDescription(e.target.value)}
|
|
placeholder="Enter short description..."
|
|
rows={3}
|
|
className="w-full bg-ink-50 border border-ink-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400 resize-none"
|
|
/>
|
|
</div>
|
|
|
|
{activeAsset.type !== 'url' && (
|
|
<div className="flex items-center gap-3 p-3 bg-ink-50 border border-ink-200 rounded-xl">
|
|
<input
|
|
type="checkbox"
|
|
id="editIsDownloadable"
|
|
checked={editIsDownloadable}
|
|
onChange={(e) => setEditIsDownloadable(e.target.checked)}
|
|
className="w-4 h-4 rounded border-ink-300 text-ink-900 focus:ring-ink-900/10 cursor-pointer"
|
|
/>
|
|
<div>
|
|
<label htmlFor="editIsDownloadable" className="text-xs font-bold text-ink-900 cursor-pointer block">
|
|
Allow Direct Download (Strict View Only if unchecked)
|
|
</label>
|
|
<span className="text-[10px] text-ink-450">
|
|
Toggle client authorization requirement for asset downloads.
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<label className="text-[10px] font-bold uppercase tracking-wider text-ink-500 mb-1.5 block">Tags (comma-separated)</label>
|
|
<input
|
|
type="text"
|
|
value={editTags}
|
|
onChange={(e) => setEditTags(e.target.value)}
|
|
placeholder="branding, guideline, pitch"
|
|
className="w-full bg-ink-50 border border-ink-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-[10px] font-bold uppercase tracking-wider text-ink-500 mb-1.5 block">GitHub/Documentation URL (Optional)</label>
|
|
<input
|
|
type="url"
|
|
value={editGithubUrl}
|
|
onChange={(e) => setEditGithubUrl(e.target.value)}
|
|
placeholder="https://github.com/..."
|
|
className="w-full bg-ink-50 border border-ink-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400"
|
|
/>
|
|
</div>
|
|
|
|
<div className="pt-4 border-t border-ink-100 flex justify-end gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsEditOpen(false)}
|
|
className="px-5 py-3 rounded-xl border border-ink-200 text-ink-700 text-xs font-bold hover:bg-ink-50 transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={isSavingEdit}
|
|
className="px-6 py-3 rounded-xl bg-ink-900 text-ink-0 text-xs font-bold hover:bg-ink-800 transition-colors shadow-sm disabled:opacity-50"
|
|
>
|
|
{isSavingEdit ? 'Saving...' : 'Save Changes'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</motion.div>
|
|
</div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* Share settings Modal with Dropdown list */}
|
|
<AnimatePresence>
|
|
{isShareOpen && activeAsset && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
onClick={() => setIsShareOpen(false)}
|
|
className="absolute inset-0 bg-ink-950/40 backdrop-blur-sm"
|
|
/>
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
|
className="relative bg-ink-0 border border-ink-200 rounded-3xl p-8 max-w-lg w-full shadow-2xl z-10 space-y-6"
|
|
>
|
|
<div className="flex justify-between items-center pb-4 border-b border-ink-100">
|
|
<div>
|
|
<h3 className="text-lg font-bold text-ink-900">Share Settings</h3>
|
|
<p className="text-xs text-ink-500 mt-0.5">{activeAsset.title}</p>
|
|
</div>
|
|
<button
|
|
onClick={() => setIsShareOpen(false)}
|
|
className="p-1 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-colors"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<form onSubmit={handleShareSubmit} className="space-y-4">
|
|
<p className="text-xs text-ink-650 leading-relaxed">
|
|
Select organizations or expand to specify exact users that can access this asset:
|
|
</p>
|
|
|
|
<div className="max-h-72 overflow-y-auto border border-ink-200 rounded-2xl divide-y divide-ink-200 bg-ink-50">
|
|
{organizations.length === 0 ? (
|
|
<p className="p-4 text-xs text-ink-500 text-center">No partner organizations registered yet.</p>
|
|
) : (
|
|
organizations.map(org => {
|
|
const isEntireShared = isOrgSharedEntirely(org.id);
|
|
const isExpanded = expandedOrgId === org.id;
|
|
const activeUsers = org.users || [];
|
|
|
|
// Count of specifically shared users in this org
|
|
const specificSharedCount = sharesList.filter(s => s.organizationId === org.id && s.userId !== null).length;
|
|
|
|
return (
|
|
<div key={org.id} className="flex flex-col">
|
|
<div className="flex items-center justify-between p-3.5 hover:bg-ink-100 transition-colors">
|
|
<label className="flex items-center gap-3 cursor-pointer flex-1">
|
|
<input
|
|
type="checkbox"
|
|
checked={isEntireShared}
|
|
onChange={() => handleToggleOrg(org.id)}
|
|
className="w-4 h-4 rounded border-ink-300 text-ink-900 focus:ring-ink-900/10 cursor-pointer"
|
|
/>
|
|
<div className="flex flex-col">
|
|
<span className="text-xs font-bold text-ink-900">{org.name}</span>
|
|
{specificSharedCount > 0 && !isEntireShared && (
|
|
<span className="text-[10px] text-ink-500 font-semibold">
|
|
Shared with {specificSharedCount} specific {specificSharedCount === 1 ? 'user' : 'users'}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</label>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => setExpandedOrgId(isExpanded ? null : org.id)}
|
|
className="p-1.5 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-200 transition-colors flex items-center gap-1 text-[11px] font-bold"
|
|
>
|
|
<span>Users</span>
|
|
{isExpanded ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Users dropdown list */}
|
|
<AnimatePresence>
|
|
{isExpanded && (
|
|
<motion.div
|
|
initial={{ height: 0, opacity: 0 }}
|
|
animate={{ height: 'auto', opacity: 1 }}
|
|
exit={{ height: 0, opacity: 0 }}
|
|
className="bg-ink-100 border-t border-b border-ink-200 overflow-hidden divide-y divide-ink-150"
|
|
>
|
|
{activeUsers.length === 0 ? (
|
|
<p className="p-3 text-[10px] text-ink-450 italic">No users found in this organization.</p>
|
|
) : (
|
|
activeUsers.map(userItem => {
|
|
const isUserShared = isUserSharedSpecifically(org.id, userItem.id);
|
|
|
|
return (
|
|
<label key={userItem.id} className="flex items-center gap-3 py-2 px-8 cursor-pointer hover:bg-ink-200/50 transition-all">
|
|
<input
|
|
type="checkbox"
|
|
disabled={isEntireShared}
|
|
checked={isEntireShared || isUserShared}
|
|
onChange={() => handleToggleUser(org.id, userItem.id)}
|
|
className="w-3.5 h-3.5 rounded border-ink-300 text-ink-900 focus:ring-ink-900/10 cursor-pointer disabled:opacity-50"
|
|
/>
|
|
<span className={`text-[11px] font-semibold ${isEntireShared ? 'text-ink-400' : 'text-ink-800'}`}>
|
|
{userItem.email}
|
|
</span>
|
|
</label>
|
|
);
|
|
})
|
|
)}
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
|
|
<div className="pt-4 border-t border-ink-100 flex justify-end gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsShareOpen(false)}
|
|
className="px-5 py-3 rounded-xl border border-ink-200 text-ink-700 text-xs font-bold hover:bg-ink-50 transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={isSavingShare}
|
|
className="px-6 py-3 rounded-xl bg-ink-900 text-ink-0 text-xs font-bold hover:bg-ink-800 transition-colors shadow-sm disabled:opacity-50"
|
|
>
|
|
{isSavingShare ? 'Saving...' : 'Update Shares'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</motion.div>
|
|
</div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* Online Document & URL Viewer Modal */}
|
|
<AnimatePresence>
|
|
{isViewerOpen && activeAsset && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
onClick={() => setIsViewerOpen(false)}
|
|
className="absolute inset-0 bg-ink-950/60 backdrop-blur-md"
|
|
/>
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.98, y: 15 }}
|
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
exit={{ opacity: 0, scale: 0.98, y: 15 }}
|
|
className={`relative bg-ink-0 border border-ink-250 rounded-3xl p-6 shadow-2xl z-10 flex flex-col overflow-hidden gap-4 transition-all duration-300 ${
|
|
isMaximized ? 'w-[96vw] h-[92vh] max-h-[92vh] max-w-none' : 'max-w-5xl w-full max-h-[85vh]'
|
|
}`}
|
|
>
|
|
<div className="flex justify-between items-center pb-3 border-b border-ink-100 flex-shrink-0">
|
|
<div>
|
|
<h3 className="text-lg font-bold text-ink-900">{activeAsset.title}</h3>
|
|
<p className="text-xs text-ink-450">
|
|
{activeAsset.type === 'url' ? 'External Web Link' : `${activeAsset.type} • ${formatBytes(activeAsset.size)}`}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
<button
|
|
onClick={() => setIsMaximized(!isMaximized)}
|
|
className="p-1.5 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-colors"
|
|
title={isMaximized ? "Collapse view" : "Expand view"}
|
|
>
|
|
{isMaximized ? <Minimize2 className="w-5 h-5" /> : <Maximize2 className="w-5 h-5" />}
|
|
</button>
|
|
<button
|
|
onClick={() => { setIsViewerOpen(false); setIsMaximized(false); }}
|
|
className="p-1 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-colors"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 w-full bg-ink-50 rounded-2xl flex flex-col items-center justify-center overflow-hidden border border-ink-200 min-h-0">
|
|
{activeAsset.type === 'url' && activeAsset.url.includes('github.com') ? (
|
|
<div className="w-full h-full flex flex-col min-h-0 bg-ink-0">
|
|
<div className="bg-ink-100 px-4 py-2.5 border-b border-ink-200 flex justify-between items-center text-xs text-ink-600 font-bold select-none">
|
|
<span className="flex items-center gap-1.5">
|
|
<Globe className="w-4 h-4 text-ink-950" />
|
|
<span>Embedded GitHub Document</span>
|
|
</span>
|
|
</div>
|
|
<div className="flex-1 overflow-auto p-6 text-left select-text bg-ink-0 text-ink-800 font-sans leading-relaxed">
|
|
{isLoadingText ? (
|
|
<div className="flex flex-col items-center justify-center h-full space-y-3">
|
|
<div className="w-8 h-8 rounded-full border-4 border-ink-900 border-t-transparent animate-spin" />
|
|
<span className="text-xs text-ink-500 font-medium">Fetching README.md...</span>
|
|
</div>
|
|
) : (
|
|
<div className="max-w-3xl mx-auto space-y-4">
|
|
<div className="border-b border-ink-200 pb-4 mb-6">
|
|
<h1 className="text-2xl font-extrabold text-ink-950">{activeAsset.title}</h1>
|
|
<p className="text-xs text-ink-450 mt-1 font-mono">{activeAsset.url}</p>
|
|
</div>
|
|
<pre className="whitespace-pre-wrap font-sans text-sm text-ink-800 break-words leading-relaxed">
|
|
{textPreviewContent}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : activeAsset.type === 'url' ? (
|
|
<div className="text-center p-12 max-w-md space-y-4 flex flex-col justify-center items-center">
|
|
<div className="w-16 h-16 rounded-2xl bg-ink-100 border border-ink-200 flex items-center justify-center text-ink-900 shadow-sm">
|
|
<Globe className="w-8 h-8" />
|
|
</div>
|
|
<div>
|
|
<h4 className="text-base font-bold text-ink-900">External Resource Portal</h4>
|
|
<p className="text-xs text-ink-500 mt-2 leading-relaxed">
|
|
This asset points to an external destination outside of the local CDN container.
|
|
</p>
|
|
<div className="bg-ink-100 border border-ink-200 rounded-xl px-4 py-2 text-xs font-mono text-ink-600 truncate mt-3 max-w-sm">
|
|
{activeAsset.url}
|
|
</div>
|
|
</div>
|
|
|
|
{(user?.role === 'ADMIN' || activeAsset.isDownloadable || activeAsset.downloadRequests?.[0]?.status === 'APPROVED') ? (
|
|
<a
|
|
href={activeAsset.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="inline-flex items-center justify-center gap-2 px-6 py-3 rounded-xl bg-ink-900 text-ink-0 text-xs font-bold hover:bg-ink-800 transition-all shadow-sm w-full"
|
|
>
|
|
<span>Open Link in New Tab</span>
|
|
<ExternalLink className="w-4 h-4" />
|
|
</a>
|
|
) : (
|
|
<div className="bg-amber-50 border border-amber-250 text-amber-900 rounded-2xl p-4 text-xs text-center font-medium max-w-sm">
|
|
Access Restricted: You must request and receive download approval from the Administrator to open this resource link.
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : activeAsset.type.includes('pdf') ? (
|
|
<div className="w-full h-full flex flex-col min-h-0">
|
|
<div className="bg-ink-100 px-4 py-2 border-b border-ink-200 flex items-center text-xs text-ink-600 font-bold select-none">
|
|
<FileText className="w-4 h-4 text-ink-950 mr-1.5" />
|
|
<span>Interactive PDF Preview</span>
|
|
</div>
|
|
<iframe
|
|
src={getFullAssetUrl(activeAsset.url)}
|
|
className="w-full flex-1 border-0 min-h-0"
|
|
title={activeAsset.title}
|
|
/>
|
|
</div>
|
|
) : (activeAsset.url.toLowerCase().endsWith('.md') || activeAsset.url.toLowerCase().endsWith('.txt') || activeAsset.type.includes('text') || activeAsset.type.includes('markdown')) ? (
|
|
<div className="w-full h-full flex flex-col min-h-0 bg-ink-0">
|
|
<div className="bg-ink-100 px-4 py-2 border-b border-ink-200 flex items-center text-xs text-ink-600 font-bold select-none">
|
|
<FileText className="w-4 h-4 text-ink-950 mr-1.5" />
|
|
<span>Document Reader</span>
|
|
</div>
|
|
<div className="flex-1 overflow-auto p-6 text-left select-text bg-ink-0 text-ink-800 font-sans leading-relaxed">
|
|
{isLoadingText ? (
|
|
<div className="flex flex-col items-center justify-center h-full space-y-3">
|
|
<div className="w-8 h-8 rounded-full border-4 border-ink-900 border-t-transparent animate-spin" />
|
|
<span className="text-xs text-ink-500 font-medium">Loading content...</span>
|
|
</div>
|
|
) : (
|
|
<div className="max-w-3xl mx-auto space-y-4">
|
|
<div className="border-b border-ink-200 pb-4 mb-6">
|
|
<h1 className="text-2xl font-extrabold text-ink-950">{activeAsset.title}</h1>
|
|
<p className="text-xs text-ink-450 mt-1">Plain Text / Markdown Format</p>
|
|
</div>
|
|
<pre className="whitespace-pre-wrap font-sans text-sm text-ink-800 break-words leading-relaxed">
|
|
{textPreviewContent}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : (activeAsset.type.includes('word') || activeAsset.type.includes('presentation') || activeAsset.type.includes('sheet') || activeAsset.url.toLowerCase().endsWith('.docx') || activeAsset.url.toLowerCase().endsWith('.doc') || activeAsset.url.toLowerCase().endsWith('.pptx') || activeAsset.url.toLowerCase().endsWith('.ppt') || activeAsset.url.toLowerCase().endsWith('.xlsx') || activeAsset.url.toLowerCase().endsWith('.xls')) ? (
|
|
<div className="w-full h-full flex flex-col min-h-0 bg-ink-0">
|
|
<div className="bg-ink-100 px-4 py-2 border-b border-ink-200 flex items-center text-xs text-ink-600 font-bold select-none">
|
|
<FileText className="w-4 h-4 text-ink-950 mr-1.5" />
|
|
<span>Office Document Preview</span>
|
|
</div>
|
|
{isLocalUrl(activeAsset.url) ? (
|
|
<div className="flex-1 p-8 text-center flex flex-col justify-center items-center max-w-lg mx-auto space-y-4 bg-ink-0">
|
|
<div className="w-16 h-16 rounded-2xl bg-ink-50 border border-ink-100 flex items-center justify-center text-ink-900 shadow-sm">
|
|
<FileText className="w-8 h-8" />
|
|
</div>
|
|
<div>
|
|
<h4 className="text-base font-bold text-ink-900">Office Document Preview</h4>
|
|
<p className="text-xs text-ink-500 mt-2 leading-relaxed">
|
|
This asset is a Microsoft Office document ({activeAsset.type.split('/').pop()?.toUpperCase() || 'DOCX'}).
|
|
</p>
|
|
<p className="text-xs text-ink-500 mt-2 leading-relaxed bg-ink-50 border border-ink-100 rounded-xl p-3 text-left">
|
|
<strong>Note:</strong> Microsoft Office Online Viewer is optimized for staging/production environments. In development (localhost), external services cannot fetch local files. Please download this asset using the button below to view it locally.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<iframe
|
|
src={`https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(getFullAssetUrl(activeAsset.url))}`}
|
|
className="w-full flex-1 border-0 min-h-0"
|
|
title={activeAsset.title}
|
|
/>
|
|
)}
|
|
</div>
|
|
) : activeAsset.type.includes('image') || activeAsset.type.includes('png') || activeAsset.type.includes('jpg') ? (
|
|
<div className="w-full h-full flex items-center justify-center p-4">
|
|
<img
|
|
src={getFullAssetUrl(activeAsset.url)}
|
|
alt={activeAsset.title}
|
|
className="max-w-full max-h-full object-contain rounded-lg shadow-sm"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="text-center p-8 flex flex-col justify-center items-center">
|
|
<File className="w-16 h-16 text-ink-300 mb-4" />
|
|
<h4 className="text-sm font-bold text-ink-900">Direct Preview Unsupported</h4>
|
|
<p className="text-xs text-ink-500 mt-1 max-w-sm">
|
|
This file format cannot be rendered directly in the browser. Please download the file to inspect its contents.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="pt-3 border-t border-ink-100 flex justify-end gap-3 flex-shrink-0">
|
|
<button
|
|
onClick={() => { setIsViewerOpen(false); setIsMaximized(false); }}
|
|
className="px-5 py-3 rounded-xl border border-ink-200 text-ink-700 text-xs font-bold hover:bg-ink-50 transition-colors"
|
|
>
|
|
Close Preview
|
|
</button>
|
|
|
|
{activeAsset.type !== 'url' && (user?.role === 'ADMIN' || activeAsset.isDownloadable || activeAsset.downloadRequests?.[0]?.status === 'APPROVED') && (
|
|
<button
|
|
onClick={() => handleDownload(activeAsset)}
|
|
className="px-6 py-3 rounded-xl bg-ink-900 text-ink-0 text-xs font-bold hover:bg-ink-800 transition-colors shadow-sm flex items-center gap-2"
|
|
>
|
|
<Download className="w-4 h-4" />
|
|
<span>Download File</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
</div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* Admin Download Requests Review Panel Modal */}
|
|
<AnimatePresence>
|
|
{isRequestsOpen && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
onClick={() => setIsRequestsOpen(false)}
|
|
className="absolute inset-0 bg-ink-950/40 backdrop-blur-sm"
|
|
/>
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
|
className="relative bg-ink-0 border border-ink-200 rounded-3xl p-8 max-w-2xl w-full shadow-2xl z-10 space-y-6 max-h-[80vh] overflow-y-auto"
|
|
>
|
|
<div className="flex justify-between items-center pb-4 border-b border-ink-100">
|
|
<div>
|
|
<h3 className="text-xl font-bold text-ink-900">Pending Download Requests</h3>
|
|
<p className="text-xs text-ink-500 mt-0.5">Review and approve download access for protected secret assets.</p>
|
|
</div>
|
|
<button
|
|
onClick={() => setIsRequestsOpen(false)}
|
|
className="p-1 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-colors"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
{assets.every(a => !a.downloadRequests?.some(r => r.status === 'PENDING')) ? (
|
|
<div className="text-center py-8">
|
|
<Check className="w-8 h-8 text-ink-400 mx-auto mb-2" />
|
|
<p className="text-xs font-bold text-ink-900">All caught up!</p>
|
|
<p className="text-[10px] text-ink-500">There are no pending download authorization requests.</p>
|
|
</div>
|
|
) : (
|
|
assets.flatMap(asset =>
|
|
(asset.downloadRequests || [])
|
|
.filter(req => req.status === 'PENDING')
|
|
.map(req => (
|
|
<div key={req.id} className="flex flex-col sm:flex-row sm:items-center justify-between p-4 bg-ink-50 border border-ink-200 rounded-xl gap-4">
|
|
<div>
|
|
<p className="text-xs font-bold text-ink-900">{req.user?.email}</p>
|
|
<p className="text-[10px] text-ink-500 mt-0.5 font-medium">
|
|
Requested download for: <span className="text-ink-900 font-bold">{asset.title}</span>
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 self-end sm:self-center">
|
|
<button
|
|
onClick={() => handleRejectRequest(asset.id, req.id)}
|
|
className="px-3.5 py-1.5 rounded-lg border border-red-200 text-red-650 hover:bg-red-500/10 text-xs font-bold transition-all"
|
|
>
|
|
Reject
|
|
</button>
|
|
<button
|
|
onClick={() => handleApproveRequest(asset.id, req.id)}
|
|
className="px-4 py-1.5 rounded-lg bg-ink-900 text-ink-0 hover:bg-ink-800 text-xs font-bold transition-all shadow-sm"
|
|
>
|
|
Approve Access
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))
|
|
)
|
|
)}
|
|
</div>
|
|
|
|
<div className="pt-4 border-t border-ink-100 flex justify-end">
|
|
<button
|
|
onClick={() => setIsRequestsOpen(false)}
|
|
className="px-6 py-3 rounded-xl bg-ink-900 text-ink-0 text-xs font-bold hover:bg-ink-800 transition-colors shadow-sm"
|
|
>
|
|
Close
|
|
</button>
|
|
</div>
|
|
</motion.div>
|
|
</div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* Asset Details Modal */}
|
|
<AnimatePresence>
|
|
{isDetailsOpen && activeAsset && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
onClick={() => setIsDetailsOpen(false)}
|
|
className="absolute inset-0 bg-ink-950/40 backdrop-blur-sm"
|
|
/>
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
|
className="relative bg-ink-0 border border-ink-200 rounded-3xl p-8 max-w-md w-full shadow-2xl z-10 space-y-6 text-ink-900"
|
|
>
|
|
<div className="flex justify-between items-center pb-4 border-b border-ink-100">
|
|
<h3 className="text-lg font-bold text-ink-900">Asset Details</h3>
|
|
<button
|
|
onClick={() => setIsDetailsOpen(false)}
|
|
className="p-1 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-colors"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="space-y-4 text-xs font-medium">
|
|
<div>
|
|
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500">Title</h4>
|
|
<p className="text-sm font-extrabold text-ink-900 mt-1">{activeAsset.title}</p>
|
|
</div>
|
|
|
|
{activeAsset.description && (
|
|
<div>
|
|
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500">Description</h4>
|
|
<p className="text-ink-700 mt-1 leading-relaxed">{activeAsset.description}</p>
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500">Category</h4>
|
|
<p className="text-ink-900 mt-1 font-bold">{activeAsset.categoryId || 'General'}</p>
|
|
</div>
|
|
<div>
|
|
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500">Subcategory</h4>
|
|
<p className="text-ink-900 mt-1 font-bold">{activeAsset.subcategory || '-'}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500">File Size</h4>
|
|
<p className="text-ink-900 mt-1 font-bold">{activeAsset.type === 'url' ? 'N/A' : formatBytes(activeAsset.size)}</p>
|
|
</div>
|
|
<div>
|
|
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500">File Type</h4>
|
|
<p className="text-ink-900 mt-1 font-bold">{activeAsset.type}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{activeAsset.tags.length > 0 && (
|
|
<div>
|
|
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500">Tags</h4>
|
|
<div className="flex flex-wrap gap-1.5 mt-1.5">
|
|
{activeAsset.tags.map(tag => (
|
|
<span key={tag} className="px-2 py-0.5 rounded bg-ink-50 border border-ink-200 text-[10px] font-bold text-ink-600">
|
|
{tag}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{user?.role === 'ADMIN' && activeAsset.sharedWith && (
|
|
<div>
|
|
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500">Shared With</h4>
|
|
<div className="flex flex-wrap gap-1.5 mt-1.5">
|
|
{activeAsset.sharedWith.length === 0 ? (
|
|
<span className="text-ink-450 font-semibold">Not shared with any organization</span>
|
|
) : (
|
|
activeAsset.sharedWith.map(sw => (
|
|
<span key={sw.userId ? `${sw.organizationId}-${sw.userId}` : sw.organizationId} className="px-2 py-0.5 rounded bg-ink-900 text-ink-0 text-[10px] font-bold">
|
|
{sw.organization.name} {sw.user ? `(${sw.user.email})` : '(Entire Org)'}
|
|
</span>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="pt-4 border-t border-ink-100 flex justify-end">
|
|
<button
|
|
onClick={() => setIsDetailsOpen(false)}
|
|
className="px-6 py-3 rounded-xl bg-ink-900 text-ink-0 text-xs font-bold hover:bg-ink-800 transition-colors shadow-sm"
|
|
>
|
|
Close
|
|
</button>
|
|
</div>
|
|
</motion.div>
|
|
</div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
</motion.div>
|
|
);
|
|
};
|
|
export default AssetsPage;
|