implement collapsible sidebar and add asset management system with modals and hooks
This commit is contained in:
parent
aef4a3ec8b
commit
19601f2ae7
6
.gitignore
vendored
6
.gitignore
vendored
@ -24,4 +24,8 @@ dist-ssr
|
||||
*.sw?
|
||||
|
||||
.env
|
||||
uploads/*
|
||||
uploads/*
|
||||
.github/*
|
||||
.next/*
|
||||
.env.local
|
||||
.vite/*
|
||||
360
Channel-Frontend/frontend.md
Normal file
360
Channel-Frontend/frontend.md
Normal file
@ -0,0 +1,360 @@
|
||||
# Frontend Refactoring & UI Standardization Prompt
|
||||
|
||||
Act as a world-class Senior Frontend Architect, UI Engineer, and Design System expert. Your objective is to refactor and standardize the entire frontend application to production-grade quality by following industry best practices, scalable architecture, and clean code principles.
|
||||
|
||||
## Primary Goal
|
||||
|
||||
Transform the existing application into a clean, maintainable, reusable, responsive, and highly consistent frontend without changing any business logic or existing functionality.
|
||||
|
||||
---
|
||||
|
||||
# 1. Frontend Architecture
|
||||
|
||||
Implement a scalable frontend architecture by following feature-based organization.
|
||||
|
||||
- Organize code into reusable modules.
|
||||
- Separate business logic from UI.
|
||||
- Keep components focused on a single responsibility.
|
||||
- Avoid duplicated logic throughout the application.
|
||||
- Create a clear folder structure.
|
||||
- Use shared utilities wherever possible.
|
||||
- Follow feature-first architecture instead of dumping everything into common folders.
|
||||
|
||||
---
|
||||
|
||||
# 2. Code Standards
|
||||
|
||||
Enforce strict coding standards throughout the project.
|
||||
|
||||
- No component should exceed **500 lines of code**.
|
||||
- Prefer keeping components between **150–300 lines** whenever possible.
|
||||
- Split large pages into smaller reusable components.
|
||||
- Extract repeated JSX into reusable components.
|
||||
- Extract repeated logic into custom hooks.
|
||||
- Move API logic into service files.
|
||||
- Move constants into dedicated constant files.
|
||||
- Move validation into separate files.
|
||||
- Move helper functions into utility files.
|
||||
|
||||
---
|
||||
|
||||
# 3. DRY Principles
|
||||
|
||||
Strictly follow the DRY (Don't Repeat Yourself) principle.
|
||||
|
||||
- Remove duplicated UI.
|
||||
- Remove duplicated styles.
|
||||
- Remove duplicated business logic.
|
||||
- Remove duplicated API calls.
|
||||
- Reuse existing components instead of recreating them.
|
||||
- Centralize repeated logic.
|
||||
|
||||
---
|
||||
|
||||
# 4. Reusable Component Library
|
||||
|
||||
Create reusable components for all common UI patterns.
|
||||
|
||||
Examples include:
|
||||
|
||||
- Button
|
||||
- Input
|
||||
- Textarea
|
||||
- Select
|
||||
- Multi Select
|
||||
- Search Input
|
||||
- Date Picker
|
||||
- Combobox
|
||||
- Modal
|
||||
- Drawer
|
||||
- Dialog
|
||||
- Confirmation Dialog
|
||||
- Table
|
||||
- Pagination
|
||||
- Badge
|
||||
- Card
|
||||
- Empty State
|
||||
- Loading State
|
||||
- Skeleton
|
||||
- Avatar
|
||||
- Tooltip
|
||||
- Popover
|
||||
- Dropdown
|
||||
- Tabs
|
||||
- Accordion
|
||||
- Alert
|
||||
- Toast
|
||||
- Breadcrumb
|
||||
- Status Badge
|
||||
- Section Header
|
||||
- Form Field Wrapper
|
||||
- Page Header
|
||||
- Filter Panel
|
||||
- Search Bar
|
||||
- Action Buttons
|
||||
- Data Cards
|
||||
|
||||
Every page should utilize these common components instead of creating new ones.
|
||||
|
||||
---
|
||||
|
||||
# 5. Typography System
|
||||
|
||||
Create a consistent typography system across the application.
|
||||
|
||||
Current typography appears oversized. Refine it to create a more professional enterprise UI.
|
||||
|
||||
Standardize:
|
||||
|
||||
- Font Family
|
||||
- Font Size
|
||||
- Font Weight
|
||||
- Line Height
|
||||
- Letter Spacing
|
||||
|
||||
Suggested scale:
|
||||
|
||||
- Page Title: 24px
|
||||
- Section Title: 20px
|
||||
- Card Title: 18px
|
||||
- Table Header: 13px
|
||||
- Body Text: 14px
|
||||
- Secondary Text: 13px
|
||||
- Labels: 13px
|
||||
- Input Text: 14px
|
||||
- Button Text: 14px
|
||||
- Caption: 12px
|
||||
|
||||
Maintain consistent typography across all screens.
|
||||
|
||||
---
|
||||
|
||||
# 6. Spacing System
|
||||
|
||||
Implement a consistent spacing system.
|
||||
|
||||
Standardize:
|
||||
|
||||
- Padding
|
||||
- Margin
|
||||
- Gap
|
||||
- Section spacing
|
||||
- Card spacing
|
||||
- Form spacing
|
||||
|
||||
Avoid random spacing values throughout the application.
|
||||
|
||||
---
|
||||
|
||||
# 7. Component Sizing
|
||||
|
||||
Reduce oversized UI elements to create a cleaner, more compact enterprise application.
|
||||
|
||||
Update:
|
||||
|
||||
- Popup sizes
|
||||
- Modal widths
|
||||
- Dialog heights
|
||||
- Table row heights
|
||||
- Table header heights
|
||||
- Form fields
|
||||
- Buttons
|
||||
- Dropdowns
|
||||
- Cards
|
||||
- Navigation
|
||||
- Sidebar spacing
|
||||
|
||||
The interface should feel lightweight, balanced, and visually polished.
|
||||
|
||||
---
|
||||
|
||||
# 8. Tables
|
||||
|
||||
Refactor all tables to follow consistent standards.
|
||||
|
||||
Ensure:
|
||||
|
||||
- Compact row height
|
||||
- Consistent header styling
|
||||
- Uniform padding
|
||||
- Better column spacing
|
||||
- Proper alignment
|
||||
- Responsive behavior
|
||||
- Sticky headers where appropriate
|
||||
- Reusable table component
|
||||
- Pagination component
|
||||
- Empty state
|
||||
- Loading state
|
||||
- Sort indicators
|
||||
- Filter integration
|
||||
|
||||
---
|
||||
|
||||
# 9. Modal & Popup Standards
|
||||
|
||||
Standardize all modals.
|
||||
|
||||
Requirements:
|
||||
|
||||
- Consistent width
|
||||
- Consistent padding
|
||||
- Consistent header
|
||||
- Consistent footer
|
||||
- Standard button placement
|
||||
- Responsive behavior
|
||||
- Smooth animations
|
||||
- Proper scroll handling
|
||||
- Keyboard accessibility
|
||||
- Reusable modal component
|
||||
|
||||
Avoid oversized dialogs.
|
||||
|
||||
---
|
||||
|
||||
# 10. Form Standards
|
||||
|
||||
All forms should follow a single design system.
|
||||
|
||||
Maintain consistency for:
|
||||
|
||||
- Label placement
|
||||
- Required indicators
|
||||
- Validation messages
|
||||
- Error states
|
||||
- Success states
|
||||
- Disabled states
|
||||
- Placeholder styles
|
||||
- Input heights
|
||||
- Border radius
|
||||
- Focus styles
|
||||
|
||||
---
|
||||
|
||||
# 11. Color System
|
||||
|
||||
Centralize all colors.
|
||||
|
||||
Do not hardcode colors.
|
||||
|
||||
Use semantic color tokens such as:
|
||||
|
||||
- Primary
|
||||
- Secondary
|
||||
- Success
|
||||
- Warning
|
||||
- Error
|
||||
- Info
|
||||
- Background
|
||||
- Surface
|
||||
- Border
|
||||
- Text Primary
|
||||
- Text Secondary
|
||||
|
||||
---
|
||||
|
||||
# 12. Responsive Standards
|
||||
|
||||
Ensure the application is fully responsive.
|
||||
|
||||
Optimize for:
|
||||
|
||||
- Mobile
|
||||
- Tablet
|
||||
- Laptop
|
||||
- Desktop
|
||||
- Large screens
|
||||
|
||||
No layout should break across screen sizes.
|
||||
|
||||
---
|
||||
|
||||
# 13. Performance
|
||||
|
||||
Improve frontend performance.
|
||||
|
||||
- Lazy load pages.
|
||||
- Lazy load heavy components.
|
||||
- Memoize expensive computations.
|
||||
- Avoid unnecessary re-renders.
|
||||
- Optimize bundle size.
|
||||
- Remove unused code.
|
||||
- Optimize images and icons.
|
||||
|
||||
---
|
||||
|
||||
# 14. Accessibility
|
||||
|
||||
Follow accessibility best practices.
|
||||
|
||||
- Keyboard navigation
|
||||
- Proper ARIA attributes
|
||||
- Focus management
|
||||
- Color contrast
|
||||
- Semantic HTML
|
||||
- Screen reader support
|
||||
|
||||
---
|
||||
|
||||
# 15. Folder Structure
|
||||
|
||||
Maintain a clean folder structure.
|
||||
|
||||
- components/
|
||||
- features/
|
||||
- hooks/
|
||||
- layouts/
|
||||
- pages/
|
||||
- services/
|
||||
- utils/
|
||||
- constants/
|
||||
- contexts/
|
||||
- types/
|
||||
- assets/
|
||||
- styles/
|
||||
|
||||
Every folder should have a clear responsibility.
|
||||
|
||||
---
|
||||
|
||||
# 16. Naming Conventions
|
||||
|
||||
Use consistent naming conventions.
|
||||
|
||||
- PascalCase for components
|
||||
- camelCase for variables and functions
|
||||
- UPPER_SNAKE_CASE for constants
|
||||
- Feature-based file organization
|
||||
|
||||
Avoid ambiguous names.
|
||||
|
||||
---
|
||||
|
||||
# 17. Styling Standards
|
||||
|
||||
Use a consistent styling approach.
|
||||
|
||||
- Prefer Tailwind utility classes.
|
||||
- Extract repeated class combinations into reusable components or utilities.
|
||||
- Avoid inline styles unless absolutely necessary.
|
||||
- Maintain consistent border radius, shadows, spacing, and transitions.
|
||||
|
||||
---
|
||||
|
||||
# 18. Final Deliverables
|
||||
|
||||
Refactor the entire application to ensure:
|
||||
|
||||
- Clean architecture
|
||||
- Maintainable codebase
|
||||
- Reusable components
|
||||
- Consistent typography
|
||||
- Compact and polished UI
|
||||
- Smaller, balanced popups and tables
|
||||
- Unified spacing system
|
||||
- Enterprise-grade design system
|
||||
- Fully responsive layouts
|
||||
- Improved performance
|
||||
- Better accessibility
|
||||
- Strict adherence to SOLID, DRY, KISS, and Clean Code principles
|
||||
|
||||
Do not change any business logic or application behavior. The objective is to improve code quality, maintainability, scalability, and visual consistency while preserving all existing functionality.
|
||||
@ -1,10 +1,9 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useThemeStore } from '../../hooks/use-theme';
|
||||
import { useAuthStore } from '../../hooks/use-auth';
|
||||
import { ShieldCheck, BarChart3, ClipboardCheck, FolderGit2, BookCopy, Users, LogOut, Menu, X, Sun, Moon, ChevronRight } from 'lucide-react';
|
||||
import { ShieldCheck, BarChart3, ClipboardCheck, FolderGit2, BookCopy, Users, LogOut, Menu, X, Sun, Moon, ChevronRight, ChevronLeft } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { axiosInstance } from '../../services/axios';
|
||||
|
||||
export const AdminLayout: React.FC = () => {
|
||||
const { user, logout } = useAuthStore();
|
||||
@ -12,30 +11,13 @@ export const AdminLayout: React.FC = () => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [pendingCount, setPendingCount] = useState(0);
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPendingCount = async () => {
|
||||
try {
|
||||
const res = await axiosInstance.get('/legal/pending');
|
||||
setPendingCount(res.data.length);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch pending count', err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPendingCount();
|
||||
|
||||
// Poll every 10s for real-time admin indicators
|
||||
const interval = setInterval(fetchPendingCount, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const navItems = [
|
||||
{ name: 'Partners', path: '/admin/partners', icon: Users },
|
||||
{ name: 'Approvals Queue', path: '/admin/approvals', icon: ClipboardCheck },
|
||||
@ -49,23 +31,34 @@ export const AdminLayout: React.FC = () => {
|
||||
<div className="min-h-screen flex flex-col md:flex-row bg-ink-50 text-ink-900 font-sans transition-colors duration-500 selection:bg-ink-900/10 overflow-hidden">
|
||||
|
||||
{/* ── Desktop Sidebar ── */}
|
||||
<aside className="hidden md:flex md:w-[280px] md:flex-col md:sticky md:top-0 md:h-screen bg-ink-0 border-r border-ink-200 shrink-0 z-20">
|
||||
<aside className={`hidden md:flex md:flex-col md:sticky md:top-0 md:h-screen bg-ink-0 border-r border-ink-200 shrink-0 z-20 transition-all duration-300 relative ${isCollapsed ? 'md:w-[80px]' : 'md:w-[280px]'}`}>
|
||||
|
||||
{/* Toggle Button Floating on the Border */}
|
||||
<button
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
className="hidden md:flex absolute top-9 -right-3 w-6 h-6 rounded-full border border-ink-200 bg-ink-0 text-ink-500 hover:text-ink-900 hover:shadow-md items-center justify-center transition-all z-30 cursor-pointer shadow-sm"
|
||||
title={isCollapsed ? "Expand Sidebar" : "Collapse Sidebar"}
|
||||
>
|
||||
{isCollapsed ? <ChevronRight className="w-3.5 h-3.5" /> : <ChevronLeft className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
|
||||
{/* Branding */}
|
||||
<div className="h-24 flex items-center px-8 border-b border-ink-200">
|
||||
<div className={`h-24 flex items-center border-b border-ink-200 transition-all duration-300 ${isCollapsed ? 'justify-center px-4' : 'px-8'}`}>
|
||||
<Link to="/admin" className="flex items-center gap-3 shrink-0 group">
|
||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center bg-gradient-to-br from-ink-900 to-ink-800 shadow-lg group-hover:scale-105 transition-all duration-300">
|
||||
<ShieldCheck className="w-5 h-5 text-ink-0" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-lg font-extrabold tracking-tight leading-none text-ink-900">Tech4Biz</span>
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-ink-900 mt-1">Admin Console</span>
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<div className="flex flex-col animate-fade-in">
|
||||
<span className="text-lg font-extrabold tracking-tight leading-none text-ink-900">Tech4Biz</span>
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-ink-900 mt-1">Admin Console</span>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-4 py-8 space-y-2 overflow-y-auto">
|
||||
<nav className={`flex-1 py-8 space-y-2 overflow-y-auto transition-all duration-300 ${isCollapsed ? 'px-2' : 'px-4'}`}>
|
||||
{navItems.map(item => {
|
||||
const Icon = item.icon;
|
||||
const isActive = location.pathname === item.path;
|
||||
@ -73,52 +66,58 @@ export const AdminLayout: React.FC = () => {
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300 font-semibold tracking-wide text-sm group ${
|
||||
className={`flex items-center gap-3 py-3 rounded-xl transition-all duration-300 font-semibold tracking-wide text-sm group relative ${
|
||||
isCollapsed ? 'justify-center px-0' : 'px-4'
|
||||
} ${
|
||||
isActive
|
||||
? 'bg-ink-100 text-ink-900 border border-ink-300 shadow-sm'
|
||||
: 'text-ink-500 hover:text-ink-900 hover:bg-ink-100'
|
||||
}`}
|
||||
title={isCollapsed ? item.name : undefined}
|
||||
>
|
||||
<Icon className={`w-5 h-5 transition-transform group-hover:scale-110 ${isActive ? 'text-ink-950' : 'text-ink-400 group-hover:text-ink-900'}`} />
|
||||
<span>{item.name}</span>
|
||||
{item.name === 'Approvals Queue' && pendingCount > 0 && (
|
||||
<span className="ml-auto flex h-5.5 w-5.5 items-center justify-center rounded-full bg-ink-900 text-[10px] font-extrabold text-ink-0 animate-pulse border border-ink-700 shadow-sm px-1.5 py-0.5">
|
||||
{pendingCount}
|
||||
</span>
|
||||
)}
|
||||
{isActive && !(item.name === 'Approvals Queue' && pendingCount > 0) && <ChevronRight className="w-4 h-4 ml-auto opacity-50" />}
|
||||
{!isCollapsed && <span>{item.name}</span>}
|
||||
{!isCollapsed && isActive && <ChevronRight className="w-4 h-4 ml-auto opacity-50" />}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-5 border-t border-ink-200 bg-ink-50">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p className="text-[10px] font-bold text-ink-400 uppercase tracking-widest">Appearance</p>
|
||||
<div className={`border-t border-ink-200 bg-ink-50 transition-all duration-300 ${isCollapsed ? 'p-3' : 'p-5'}`}>
|
||||
<div className={`flex items-center mb-4 transition-all duration-300 ${isCollapsed ? 'justify-center' : 'justify-between'}`}>
|
||||
{!isCollapsed && <p className="text-[10px] font-bold text-ink-400 uppercase tracking-widest">Appearance</p>}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-2 rounded-lg bg-ink-0 border border-ink-200 text-ink-500 hover:text-ink-900 hover:shadow-sm transition-all group"
|
||||
className="p-2 rounded-lg bg-ink-0 border border-ink-200 text-ink-500 hover:text-ink-900 hover:shadow-sm transition-all group cursor-pointer"
|
||||
>
|
||||
{theme === 'dark' ? <Sun className="w-4 h-4 group-hover:rotate-90 transition-transform" /> : <Moon className="w-4 h-4 group-hover:-rotate-12 transition-transform" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 rounded-2xl bg-ink-0 border border-ink-200 mb-4 shadow-sm">
|
||||
<div className="w-9 h-9 rounded-full bg-ink-900 flex items-center justify-center text-ink-0 font-bold shadow-sm">
|
||||
{!isCollapsed ? (
|
||||
<div className="flex items-center gap-3 p-3 rounded-2xl bg-ink-0 border border-ink-200 mb-4 shadow-sm">
|
||||
<div className="w-9 h-9 rounded-full bg-ink-900 flex items-center justify-center text-ink-0 font-bold shadow-sm">
|
||||
{user?.email?.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-bold text-ink-900 truncate">{user?.email}</p>
|
||||
<p className="text-[10px] uppercase font-bold text-ink-500 tracking-wider truncate">Administrator</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-9 h-9 rounded-full bg-ink-900 flex items-center justify-center text-ink-0 font-bold shadow-sm mx-auto mb-4 animate-fade-in" title={user?.email || ''}>
|
||||
{user?.email?.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-bold text-ink-900 truncate">{user?.email}</p>
|
||||
<p className="text-[10px] uppercase font-bold text-ink-500 tracking-wider truncate">Administrator</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl text-sm font-bold tracking-wide text-red-600 hover:bg-red-500/10 transition-colors border border-transparent hover:border-red-200"
|
||||
className={`w-full flex items-center justify-center gap-2 rounded-xl text-sm font-bold tracking-wide text-red-600 hover:bg-red-500/10 transition-colors border border-transparent hover:border-red-200 cursor-pointer ${isCollapsed ? 'py-2 px-0' : 'px-4 py-2.5'}`}
|
||||
title={isCollapsed ? "Sign Out" : undefined}
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
<span>Sign Out</span>
|
||||
{!isCollapsed && <span>Sign Out</span>}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
@ -158,11 +157,6 @@ export const AdminLayout: React.FC = () => {
|
||||
<Link key={item.path} to={item.path} onClick={() => setMobileOpen(false)} className={`flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-semibold ${location.pathname === item.path ? 'bg-ink-100 text-ink-900 border border-ink-300' : 'text-ink-500 hover:text-ink-900'}`}>
|
||||
<item.icon className="w-5 h-5" />
|
||||
<span>{item.name}</span>
|
||||
{item.name === 'Approvals Queue' && pendingCount > 0 && (
|
||||
<span className="ml-auto flex h-5 w-5 items-center justify-center rounded-full bg-ink-900 text-[10px] font-extrabold text-ink-0">
|
||||
{pendingCount}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
@ -177,33 +171,13 @@ export const AdminLayout: React.FC = () => {
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Dynamic Pending Approvals Banner */}
|
||||
{pendingCount > 0 && (
|
||||
<div className="bg-ink-900 text-ink-0 px-6 py-3.5 flex items-center justify-between border-b border-ink-800 shadow-sm relative overflow-hidden shrink-0">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-ink-800 via-ink-900 to-ink-800 opacity-50" />
|
||||
<div className="flex items-center gap-3 relative z-10">
|
||||
<span className="flex h-2 w-2 rounded-full bg-ink-0 animate-pulse" />
|
||||
<p className="text-xs font-bold tracking-wide">
|
||||
Onboarding Queue: {pendingCount} partner{pendingCount > 1 ? 's are' : ' is'} awaiting legal document approval.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/admin/approvals"
|
||||
className="relative z-10 text-[10px] font-extrabold uppercase tracking-widest bg-ink-0 text-ink-900 px-3.5 py-1.5 rounded-lg hover:bg-ink-100 transition-all shadow-sm flex items-center gap-1 shrink-0"
|
||||
>
|
||||
<span>Review Now</span>
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 w-full max-w-[1600px] px-6 py-10 md:px-12 mx-auto relative z-10">
|
||||
<div className="flex-1 w-full max-w-[1600px] px-4 py-6 md:px-8 mx-auto relative z-10">
|
||||
<Outlet />
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-ink-200 bg-ink-0/50 backdrop-blur-md mt-auto">
|
||||
<div className="max-w-[1600px] mx-auto px-6 md:px-12 py-6 flex flex-col md:flex-row justify-between items-center gap-4 text-xs font-medium text-ink-500">
|
||||
<div className="max-w-[1600px] mx-auto px-4 md:px-8 py-4 flex flex-col md:flex-row justify-between items-center gap-4 text-xs font-medium text-ink-500">
|
||||
<p>© 2026 Tech4Biz Solutions Inc. Admin Console.</p>
|
||||
<div className="flex gap-6">
|
||||
<span className="hover:text-ink-900 cursor-pointer transition-colors">Security Compliance</span>
|
||||
|
||||
@ -2,7 +2,7 @@ import React, { useState } from 'react';
|
||||
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useThemeStore } from '../../hooks/use-theme';
|
||||
import { useAuthStore } from '../../hooks/use-auth';
|
||||
import { ShieldCheck, Cpu, BookOpen, LogOut, CheckCircle, Clock, Menu, X, Sun, Moon, ChevronRight } from 'lucide-react';
|
||||
import { ShieldCheck, Cpu, BookOpen, LogOut, CheckCircle, Clock, Menu, X, Sun, Moon, ChevronRight, ChevronLeft } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
const navItems = [
|
||||
@ -16,6 +16,7 @@ export const ClientLayout: React.FC = () => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
@ -27,23 +28,34 @@ export const ClientLayout: React.FC = () => {
|
||||
<div className="min-h-screen flex flex-col md:flex-row bg-ink-50 text-ink-900 font-sans transition-colors duration-500 selection:bg-ink-900/10 overflow-hidden">
|
||||
|
||||
{/* ── Desktop Sidebar ── */}
|
||||
<aside className="hidden md:flex md:w-[280px] md:flex-col md:sticky md:top-0 md:h-screen bg-ink-0 border-r border-ink-200 shrink-0 z-20">
|
||||
<aside className={`hidden md:flex md:flex-col md:sticky md:top-0 md:h-screen bg-ink-0 border-r border-ink-200 shrink-0 z-20 transition-all duration-300 relative ${isCollapsed ? 'md:w-[80px]' : 'md:w-[280px]'}`}>
|
||||
|
||||
{/* Toggle Button Floating on the Border */}
|
||||
<button
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
className="hidden md:flex absolute top-9 -right-3 w-6 h-6 rounded-full border border-ink-200 bg-ink-0 text-ink-500 hover:text-ink-900 hover:shadow-md items-center justify-center transition-all z-30 cursor-pointer shadow-sm"
|
||||
title={isCollapsed ? "Expand Sidebar" : "Collapse Sidebar"}
|
||||
>
|
||||
{isCollapsed ? <ChevronRight className="w-3.5 h-3.5" /> : <ChevronLeft className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
|
||||
{/* Branding */}
|
||||
<div className="h-24 flex items-center px-8 border-b border-ink-200">
|
||||
<div className={`h-24 flex items-center border-b border-ink-200 transition-all duration-300 ${isCollapsed ? 'justify-center px-4' : 'px-8'}`}>
|
||||
<Link to="/client" className="flex items-center gap-3 shrink-0 group">
|
||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center bg-gradient-to-br from-ink-900 to-ink-800 shadow-lg group-hover:scale-105 transition-all duration-300">
|
||||
<ShieldCheck className="w-5 h-5 text-ink-0" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-lg font-extrabold tracking-tight leading-none text-ink-900">Tech4Biz</span>
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-ink-900 mt-1">Client Portal</span>
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<div className="flex flex-col animate-fade-in">
|
||||
<span className="text-lg font-extrabold tracking-tight leading-none text-ink-900">Tech4Biz</span>
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-ink-900 mt-1">Client Portal</span>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-4 py-8 space-y-2 overflow-y-auto">
|
||||
<nav className={`flex-1 py-8 space-y-2 overflow-y-auto transition-all duration-300 ${isCollapsed ? 'px-2' : 'px-4'}`}>
|
||||
{navItems.map(item => {
|
||||
const Icon = item.icon;
|
||||
const isActive = location.pathname === item.path;
|
||||
@ -51,49 +63,60 @@ export const ClientLayout: React.FC = () => {
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300 font-semibold tracking-wide text-sm group ${
|
||||
className={`flex items-center gap-3 py-3 rounded-xl transition-all duration-300 font-semibold tracking-wide text-sm group relative ${
|
||||
isCollapsed ? 'justify-center px-0' : 'px-4'
|
||||
} ${
|
||||
isActive
|
||||
? 'bg-ink-100 text-ink-900 border border-ink-300 shadow-sm'
|
||||
: 'text-ink-500 hover:text-ink-900 hover:bg-ink-100'
|
||||
}`}
|
||||
title={isCollapsed ? item.label : undefined}
|
||||
>
|
||||
<Icon className={`w-5 h-5 transition-transform group-hover:scale-110 ${isActive ? 'text-ink-950' : 'text-ink-400 group-hover:text-ink-900'}`} />
|
||||
<span>{item.label}</span>
|
||||
{isActive && <ChevronRight className="w-4 h-4 ml-auto opacity-50" />}
|
||||
{!isCollapsed && <span>{item.label}</span>}
|
||||
{!isCollapsed && isActive && <ChevronRight className="w-4 h-4 ml-auto opacity-50" />}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-5 border-t border-ink-200 bg-ink-50">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p className="text-[10px] font-bold text-ink-400 uppercase tracking-widest">Appearance</p>
|
||||
<div className={`border-t border-ink-200 bg-ink-50 transition-all duration-300 ${isCollapsed ? 'p-3' : 'p-5'}`}>
|
||||
<div className={`flex items-center mb-4 transition-all duration-300 ${isCollapsed ? 'justify-center' : 'justify-between'}`}>
|
||||
{!isCollapsed && <p className="text-[10px] font-bold text-ink-400 uppercase tracking-widest">Appearance</p>}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-2 rounded-lg bg-ink-0 border border-ink-200 text-ink-500 hover:text-ink-900 hover:shadow-sm transition-all group"
|
||||
className="p-2 rounded-lg bg-ink-0 border border-ink-200 text-ink-500 hover:text-ink-900 hover:shadow-sm transition-all group cursor-pointer"
|
||||
>
|
||||
{theme === 'dark' ? <Sun className="w-4 h-4 group-hover:rotate-90 transition-transform" /> : <Moon className="w-4 h-4 group-hover:-rotate-12 transition-transform" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 p-4 rounded-2xl bg-ink-0 border border-ink-200 mb-4 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-ink-400">Status</span>
|
||||
<div className={`flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-bold border ${isApproved ? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20' : 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20'}`}>
|
||||
{isApproved ? <CheckCircle className="w-3 h-3" /> : <Clock className="w-3 h-3" />}
|
||||
{isApproved ? 'Verified' : 'Pending'}
|
||||
{!isCollapsed ? (
|
||||
<div className="flex flex-col gap-2 p-4 rounded-2xl bg-ink-0 border border-ink-200 mb-4 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-ink-400">Status</span>
|
||||
<div className={`flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-bold border ${isApproved ? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20' : 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20'}`}>
|
||||
{isApproved ? <CheckCircle className="w-3 h-3" /> : <Clock className="w-3 h-3" />}
|
||||
{isApproved ? 'Verified' : 'Pending'}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs font-bold text-ink-900 truncate" title={user?.email || ''}>{user?.email}</p>
|
||||
</div>
|
||||
<p className="text-xs font-bold text-ink-900 truncate" title={user?.email || ''}>{user?.email}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-9 h-9 rounded-full bg-ink-900 flex items-center justify-center text-ink-0 font-bold shadow-sm mx-auto mb-4 animate-fade-in relative" title={user?.email || ''}>
|
||||
{user?.email?.charAt(0).toUpperCase()}
|
||||
<span className={`absolute -bottom-1 -right-1 w-3 h-3 rounded-full border border-ink-0 ${isApproved ? 'bg-emerald-500' : 'bg-amber-500'}`} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl text-sm font-bold tracking-wide text-red-600 hover:bg-red-500/10 transition-colors border border-transparent hover:border-red-200"
|
||||
className={`w-full flex items-center justify-center gap-2 rounded-xl text-sm font-bold tracking-wide text-red-650 hover:bg-red-500/10 transition-colors border border-transparent hover:border-red-200 cursor-pointer ${isCollapsed ? 'py-2 px-0' : 'px-4 py-2.5'}`}
|
||||
title={isCollapsed ? "Sign Out" : undefined}
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
<span>Sign Out</span>
|
||||
{!isCollapsed && <span>Sign Out</span>}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
@ -148,13 +171,13 @@ export const ClientLayout: React.FC = () => {
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="flex-1 w-full max-w-[1600px] px-6 py-10 md:px-12 mx-auto relative z-10">
|
||||
<div className="flex-1 w-full max-w-[1600px] px-4 py-6 md:px-8 mx-auto relative z-10">
|
||||
<Outlet />
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-ink-200 bg-ink-0/50 backdrop-blur-md mt-auto">
|
||||
<div className="max-w-[1600px] mx-auto px-6 md:px-12 py-6 flex flex-col md:flex-row justify-between items-center gap-4 text-xs font-medium text-ink-500">
|
||||
<div className="max-w-[1600px] mx-auto px-4 md:px-8 py-4 flex flex-col md:flex-row justify-between items-center gap-4 text-xs font-medium text-ink-500">
|
||||
<p>© 2026 Tech4Biz Solutions Inc. All rights reserved.</p>
|
||||
<div className="flex gap-6">
|
||||
<span className="hover:text-ink-900 cursor-pointer transition-colors">Security</span>
|
||||
|
||||
35
Channel-Frontend/src/components/ui/PageHeader.tsx
Normal file
35
Channel-Frontend/src/components/ui/PageHeader.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
badge?: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const PageHeader: React.FC<PageHeaderProps> = ({ title, subtitle, badge, actions }) => {
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-4 border-b border-ink-200">
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<div className="flex items-center gap-2.5 flex-wrap">
|
||||
<h1 className="text-xl font-bold tracking-tight text-ink-900 truncate">
|
||||
{title}
|
||||
</h1>
|
||||
{badge && <div className="shrink-0">{badge}</div>}
|
||||
</div>
|
||||
{subtitle && (
|
||||
<p className="text-xs font-medium text-ink-500 truncate max-w-3xl">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{actions && (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageHeader;
|
||||
229
Channel-Frontend/src/features/assets/components/AssetCard.tsx
Normal file
229
Channel-Frontend/src/features/assets/components/AssetCard.tsx
Normal file
@ -0,0 +1,229 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Globe,
|
||||
FileText,
|
||||
Image as ImageIcon,
|
||||
File,
|
||||
Eye,
|
||||
MoreVertical,
|
||||
Edit3,
|
||||
Share2,
|
||||
Trash2,
|
||||
Lock,
|
||||
ExternalLink,
|
||||
Download,
|
||||
Clock,
|
||||
AlertCircle
|
||||
} from 'lucide-react';
|
||||
import type { Asset } from '../../../types/assets';
|
||||
import type { User } from '../../../types/auth';
|
||||
|
||||
interface AssetCardProps {
|
||||
asset: Asset;
|
||||
user: User | null;
|
||||
activeMenuId: string | null;
|
||||
setActiveMenuId: (id: string | null) => void;
|
||||
onViewDetails: (asset: Asset) => void;
|
||||
onEdit: (asset: Asset) => void;
|
||||
onShare: (asset: Asset) => void;
|
||||
onDelete: (assetId: string) => void;
|
||||
onOpenViewer: (asset: Asset) => void;
|
||||
onDownload: (asset: Asset) => void;
|
||||
onRequestDownload: (asset: Asset) => void;
|
||||
}
|
||||
|
||||
export const AssetCard: React.FC<AssetCardProps> = ({
|
||||
asset,
|
||||
user,
|
||||
activeMenuId,
|
||||
setActiveMenuId,
|
||||
onViewDetails,
|
||||
onEdit,
|
||||
onShare,
|
||||
onDelete,
|
||||
onOpenViewer,
|
||||
onDownload,
|
||||
onRequestDownload
|
||||
}) => {
|
||||
const isMenuOpen = activeMenuId === asset.id;
|
||||
const canDirectDownload = user?.role === 'ADMIN' || asset.isDownloadable || asset.downloadRequests?.[0]?.status === 'APPROVED';
|
||||
const requestStatus = asset.downloadRequests?.[0]?.status;
|
||||
|
||||
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 Icon = getAssetIcon(asset.type);
|
||||
|
||||
return (
|
||||
<div className="group relative bg-ink-0 border border-ink-200 rounded-xl p-4 hover:border-ink-300 transition-all duration-300 shadow-sm hover:shadow-md hover:-translate-y-0.5 flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex justify-between items-start mb-3 relative">
|
||||
<div className="w-10 h-10 rounded-lg flex items-center justify-center text-ink-900 bg-ink-100 border border-ink-200">
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
|
||||
<div className="relative flex items-center gap-1.5">
|
||||
{isRenderable(asset.type) && (
|
||||
<button
|
||||
onClick={() => onOpenViewer(asset)}
|
||||
className="p-1 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 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-lg shadow-lg z-20 overflow-hidden py-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
onViewDetails(asset);
|
||||
setActiveMenuId(null);
|
||||
}}
|
||||
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={() => {
|
||||
onEdit(asset);
|
||||
setActiveMenuId(null);
|
||||
}}
|
||||
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={() => {
|
||||
onShare(asset);
|
||||
setActiveMenuId(null);
|
||||
}}
|
||||
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={() => {
|
||||
onDelete(asset.id);
|
||||
setActiveMenuId(null);
|
||||
}}
|
||||
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-4">
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<div className="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold text-ink-500 uppercase tracking-wider bg-ink-50 border border-ink-200">
|
||||
{asset.categoryId || 'General'}
|
||||
</div>
|
||||
{!asset.isDownloadable && (
|
||||
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] 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-sm leading-snug line-clamp-2 group-hover:text-ink-950 transition-colors" title={asset.title}>
|
||||
{asset.title}
|
||||
</h3>
|
||||
<p className="text-[11px] 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-3 border-t border-ink-100 flex items-center justify-between mt-auto">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] font-semibold 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-7 h-7 rounded-md 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-3.5 h-3.5" />
|
||||
</a>
|
||||
) : canDirectDownload ? (
|
||||
<button
|
||||
onClick={() => onDownload(asset)}
|
||||
className="w-7 h-7 rounded-md 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-3.5 h-3.5" />
|
||||
</button>
|
||||
) : requestStatus === 'PENDING' ? (
|
||||
<div className="inline-flex items-center gap-1 text-[10px] font-bold text-ink-500 bg-ink-50 border border-ink-200 px-2 py-1 rounded-md">
|
||||
<Clock className="w-3 h-3 text-ink-500 animate-pulse" />
|
||||
<span>Pending Access</span>
|
||||
</div>
|
||||
) : requestStatus === 'REJECTED' ? (
|
||||
<button
|
||||
onClick={() => onRequestDownload(asset)}
|
||||
className="inline-flex items-center gap-1 text-[10px] font-bold text-red-650 bg-red-500/10 border border-red-500/20 px-2 py-1 rounded-md hover:bg-red-500/25 transition-all"
|
||||
>
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
<span>Rejected (Retry)</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => onRequestDownload(asset)}
|
||||
className="inline-flex items-center gap-1 text-[10px] font-bold text-ink-900 bg-ink-100 border border-ink-300 hover:bg-ink-200 px-2 py-1 rounded-md transition-all cursor-pointer"
|
||||
>
|
||||
<Lock className="w-3 h-3" />
|
||||
<span>Request Download</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,134 @@
|
||||
import React from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X } from 'lucide-react';
|
||||
import type { Asset } from '../../../types/assets';
|
||||
|
||||
interface AssetDetailsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
asset: Asset | null;
|
||||
userRole: string | undefined;
|
||||
}
|
||||
|
||||
export const AssetDetailsModal: React.FC<AssetDetailsModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
asset,
|
||||
userRole
|
||||
}) => {
|
||||
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];
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && asset && (
|
||||
<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={onClose}
|
||||
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-2xl p-6 max-w-md w-full shadow-xl z-10 space-y-5 text-ink-900"
|
||||
>
|
||||
<div className="flex justify-between items-center pb-3 border-b border-ink-100">
|
||||
<h3 className="text-base font-bold text-ink-900">Asset Details</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
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">{asset.title}</p>
|
||||
</div>
|
||||
|
||||
{asset.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">{asset.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">{asset.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">{asset.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">{asset.type === 'url' ? 'N/A' : formatBytes(asset.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">{asset.type}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{asset.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">
|
||||
{asset.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>
|
||||
)}
|
||||
|
||||
{userRole === 'ADMIN' && asset.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">
|
||||
{asset.sharedWith.length === 0 ? (
|
||||
<span className="text-ink-500 font-semibold italic">Not shared with any organization</span>
|
||||
) : (
|
||||
asset.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 || 'Unknown Organization'} {sw.user ? `(${sw.user.email})` : '(Entire Org)'}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-3.5 border-t border-ink-100 flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-5 py-2 rounded-lg 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>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,345 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, Minimize2, Maximize2, Globe, ExternalLink, FileText, File, Download } from 'lucide-react';
|
||||
import { axiosInstance } from '../../../services/axios';
|
||||
import type { Asset } from '../../../types/assets';
|
||||
import type { User } from '../../../types/auth';
|
||||
|
||||
interface AssetViewerModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
asset: Asset | null;
|
||||
user: User | null;
|
||||
onDownload: (asset: Asset) => void;
|
||||
}
|
||||
|
||||
export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
asset,
|
||||
user,
|
||||
onDownload
|
||||
}) => {
|
||||
const [isMaximized, setIsMaximized] = useState(false);
|
||||
const [isLoadingText, setIsLoadingText] = useState(false);
|
||||
const [textPreviewContent, setTextPreviewContent] = useState('');
|
||||
|
||||
const isLocalUrl = (url: string) => {
|
||||
return url.includes('localhost') || url.includes('127.0.0.1');
|
||||
};
|
||||
|
||||
const getFullAssetUrl = (url: string) => {
|
||||
return url.startsWith('http')
|
||||
? url
|
||||
: `${axiosInstance.defaults.baseURL?.replace('/api/v1', '')}${url}`;
|
||||
};
|
||||
|
||||
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 (isOpen && asset) {
|
||||
setTextPreviewContent('');
|
||||
setIsLoadingText(false);
|
||||
|
||||
const assetUrl = asset.url.toLowerCase();
|
||||
|
||||
if (
|
||||
asset.type !== 'url' &&
|
||||
(assetUrl.endsWith('.md') ||
|
||||
assetUrl.endsWith('.txt') ||
|
||||
asset.type.includes('text') ||
|
||||
asset.type.includes('markdown'))
|
||||
) {
|
||||
setIsLoadingText(true);
|
||||
axiosInstance.get(getFullAssetUrl(asset.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 (asset.type === 'url' && asset.url.includes('github.com')) {
|
||||
const ghInfo = getGithubRawUrl(asset.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' ? asset.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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [isOpen, asset]);
|
||||
|
||||
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 handleClose = () => {
|
||||
setIsMaximized(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && asset && (
|
||||
<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={handleClose}
|
||||
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-2xl p-6 shadow-xl 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-base font-bold text-ink-900">{asset.title}</h3>
|
||||
<p className="text-xs text-ink-500">
|
||||
{asset.type === 'url' ? 'External Web Link' : `${asset.type} • ${formatBytes(asset.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={handleClose}
|
||||
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-xl flex flex-col items-center justify-center overflow-hidden border border-ink-200 min-h-0">
|
||||
{asset.type === 'url' && asset.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 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-6 h-6 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-xl font-extrabold text-ink-950">{asset.title}</h1>
|
||||
<p className="text-xs text-ink-500 mt-1 font-mono">{asset.url}</p>
|
||||
</div>
|
||||
<pre className="whitespace-pre-wrap font-sans text-sm text-ink-800 break-words leading-relaxed">
|
||||
{textPreviewContent}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : asset.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-sm 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">
|
||||
{asset.url}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(user?.role === 'ADMIN' || asset.isDownloadable || asset.downloadRequests?.[0]?.status === 'APPROVED') ? (
|
||||
<a
|
||||
href={asset.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center justify-center gap-2 px-6 py-2.5 rounded-lg 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-205 text-amber-900 rounded-xl 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>
|
||||
) : asset.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(asset.url)}
|
||||
className="w-full flex-1 border-0 min-h-0"
|
||||
title={asset.title}
|
||||
/>
|
||||
</div>
|
||||
) : (asset.url.toLowerCase().endsWith('.md') || asset.url.toLowerCase().endsWith('.txt') || asset.type.includes('text') || asset.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-6 h-6 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-xl font-extrabold text-ink-950">{asset.title}</h1>
|
||||
<p className="text-xs text-ink-500 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>
|
||||
) : (asset.type.includes('word') || asset.type.includes('presentation') || asset.type.includes('sheet') || asset.url.toLowerCase().endsWith('.docx') || asset.url.toLowerCase().endsWith('.doc') || asset.url.toLowerCase().endsWith('.pptx') || asset.url.toLowerCase().endsWith('.ppt') || asset.url.toLowerCase().endsWith('.xlsx') || asset.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(asset.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-55 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-sm 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 ({asset.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(asset.url))}`}
|
||||
className="w-full flex-1 border-0 min-h-0"
|
||||
title={asset.title}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : asset.type.includes('image') || asset.type.includes('png') || asset.type.includes('jpg') ? (
|
||||
<div className="w-full h-full flex items-center justify-center p-4">
|
||||
<img
|
||||
src={getFullAssetUrl(asset.url)}
|
||||
alt={asset.title}
|
||||
className="max-w-full max-h-full object-contain rounded-xl shadow-sm border border-ink-100"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center p-8 flex flex-col justify-center items-center">
|
||||
<File className="w-12 h-12 text-ink-300 mb-3" />
|
||||
<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={handleClose}
|
||||
className="px-4 py-2 rounded-lg border border-ink-200 text-ink-700 text-xs font-bold hover:bg-ink-50 transition-colors"
|
||||
>
|
||||
Close Preview
|
||||
</button>
|
||||
|
||||
{asset.type !== 'url' && (user?.role === 'ADMIN' || asset.isDownloadable || asset.downloadRequests?.[0]?.status === 'APPROVED') && (
|
||||
<button
|
||||
onClick={() => onDownload(asset)}
|
||||
className="px-5 py-2 rounded-lg 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>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,110 @@
|
||||
import React from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, Check } from 'lucide-react';
|
||||
import type { Asset } from '../../../types/assets';
|
||||
|
||||
interface DownloadRequestsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
assets: Asset[];
|
||||
onApprove: (assetId: string, requestId: string) => void;
|
||||
onReject: (assetId: string, requestId: string) => void;
|
||||
}
|
||||
|
||||
export const DownloadRequestsModal: React.FC<DownloadRequestsModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
assets,
|
||||
onApprove,
|
||||
onReject
|
||||
}) => {
|
||||
const pendingRequests = assets.flatMap(asset =>
|
||||
(asset.downloadRequests || [])
|
||||
.filter(req => req.status === 'PENDING')
|
||||
.map(req => ({
|
||||
...req,
|
||||
assetTitle: asset.title,
|
||||
assetId: asset.id
|
||||
}))
|
||||
);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<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={onClose}
|
||||
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-2xl p-6 max-w-2xl w-full shadow-xl z-10 flex flex-col max-h-[80vh] overflow-hidden"
|
||||
>
|
||||
<div className="flex justify-between items-center pb-3.5 border-b border-ink-100 flex-shrink-0">
|
||||
<div>
|
||||
<h3 className="text-lg 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={onClose}
|
||||
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="flex-1 overflow-y-auto mt-4 space-y-3 pr-1 scrollbar-thin">
|
||||
{pendingRequests.length === 0 ? (
|
||||
<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>
|
||||
) : (
|
||||
pendingRequests.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">{req.assetTitle}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 self-end sm:self-center">
|
||||
<button
|
||||
onClick={() => onReject(req.assetId, req.id)}
|
||||
className="px-3 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={() => onApprove(req.assetId, 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-3.5 border-t border-ink-100 flex justify-end flex-shrink-0 mt-4">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-5 py-2 rounded-lg 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>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,207 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X } from 'lucide-react';
|
||||
import { updateAsset } from '../../../services/assets-api';
|
||||
import type { Asset } from '../../../types/assets';
|
||||
|
||||
interface EditAssetModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
asset: Asset | null;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export const EditAssetModal: React.FC<EditAssetModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
asset,
|
||||
onSuccess
|
||||
}) => {
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
if (asset) {
|
||||
setEditTitle(asset.title);
|
||||
setEditDescription(asset.description || '');
|
||||
setEditCategory(asset.categoryId || 'Marketing');
|
||||
setEditSubcategory(asset.subcategory || '');
|
||||
setEditTags(asset.tags.join(', '));
|
||||
setEditGithubUrl(asset.githubUrl || '');
|
||||
setEditIsDownloadable(asset.isDownloadable);
|
||||
}
|
||||
}, [asset]);
|
||||
|
||||
const handleEditSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!asset) return;
|
||||
|
||||
setIsSavingEdit(true);
|
||||
try {
|
||||
await updateAsset(asset.id, {
|
||||
title: editTitle,
|
||||
description: editDescription,
|
||||
categoryId: editCategory,
|
||||
subcategory: editSubcategory,
|
||||
tags: editTags.split(',').map(t => t.trim()).filter(Boolean),
|
||||
githubUrl: editGithubUrl,
|
||||
isDownloadable: editIsDownloadable,
|
||||
});
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error('Failed to save asset details', err);
|
||||
} finally {
|
||||
setIsSavingEdit(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && asset && (
|
||||
<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={onClose}
|
||||
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-2xl p-6 max-w-lg w-full max-h-[90vh] shadow-xl z-10 flex flex-col overflow-hidden"
|
||||
>
|
||||
<div className="flex justify-between items-center pb-3.5 border-b border-ink-100 flex-shrink-0">
|
||||
<h3 className="text-lg font-bold text-ink-900">Edit Asset Details</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
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="flex-1 min-h-0 flex flex-col mt-4">
|
||||
<div className="flex-1 overflow-y-auto pr-1 space-y-4 scrollbar-thin">
|
||||
<div>
|
||||
<label className="text-xs font-semibold 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-lg px-3 py-2 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-xs font-semibold 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-lg px-3 py-2 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-xs font-semibold 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-lg px-3 py-2 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-xs font-semibold 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-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{asset.type !== 'url' && (
|
||||
<div className="flex items-center gap-3 p-3 bg-ink-50 border border-ink-200 rounded-lg">
|
||||
<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-500">
|
||||
Toggle client authorization requirement for asset downloads.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-semibold 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-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-semibold 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-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-3.5 border-t border-ink-100 flex justify-end gap-3 flex-shrink-0 mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-lg 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-5 py-2 rounded-lg 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>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,225 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { updateAsset } from '../../../services/assets-api';
|
||||
import type { Asset, Organization, ShareItem } from '../../../types/assets';
|
||||
|
||||
interface ShareAssetModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
asset: Asset | null;
|
||||
organizations: Organization[];
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export const ShareAssetModal: React.FC<ShareAssetModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
asset,
|
||||
organizations,
|
||||
onSuccess
|
||||
}) => {
|
||||
const [sharesList, setSharesList] = useState<ShareItem[]>([]);
|
||||
const [isSavingShare, setIsSavingShare] = useState(false);
|
||||
const [expandedOrgId, setExpandedOrgId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (asset) {
|
||||
setSharesList(
|
||||
asset.sharedWith?.map(s => ({
|
||||
organizationId: s.organizationId,
|
||||
userId: s.userId
|
||||
})) || []
|
||||
);
|
||||
}
|
||||
}, [asset]);
|
||||
|
||||
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) {
|
||||
setSharesList(prev => prev.filter(s => s.organizationId !== orgId));
|
||||
} else {
|
||||
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 {
|
||||
setSharesList(prev => [
|
||||
...prev.filter(s => !(s.organizationId === orgId && s.userId === null)),
|
||||
{ organizationId: orgId, userId }
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleShareSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!asset) return;
|
||||
|
||||
setIsSavingShare(true);
|
||||
try {
|
||||
await updateAsset(asset.id, {
|
||||
shares: sharesList
|
||||
});
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error('Failed to update share permissions', err);
|
||||
} finally {
|
||||
setIsSavingShare(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && asset && (
|
||||
<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={onClose}
|
||||
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-2xl p-6 max-w-lg w-full shadow-xl z-10 flex flex-col max-h-[85vh] overflow-hidden"
|
||||
>
|
||||
<div className="flex justify-between items-center pb-3.5 border-b border-ink-100 flex-shrink-0">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-ink-900">Share Settings</h3>
|
||||
<p className="text-xs text-ink-500 mt-0.5">{asset.title}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
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="flex-1 min-h-0 flex flex-col mt-4">
|
||||
<div className="flex-1 overflow-y-auto pr-1 space-y-4 scrollbar-thin">
|
||||
<p className="text-xs text-ink-600 leading-relaxed">
|
||||
Select organizations or expand to specify exact users that can access this asset:
|
||||
</p>
|
||||
|
||||
<div className="max-h-64 overflow-y-auto border border-ink-200 rounded-xl divide-y divide-ink-250 bg-ink-50 scrollbar-thin">
|
||||
{organizations.length === 0 ? (
|
||||
<p className="p-4 text-xs text-ink-500 text-center font-medium">No partner organizations registered yet.</p>
|
||||
) : (
|
||||
organizations.map(org => {
|
||||
const isEntireShared = isOrgSharedEntirely(org.id);
|
||||
const isExpanded = expandedOrgId === org.id;
|
||||
const activeUsers = org.users || [];
|
||||
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 hover:bg-ink-100 transition-colors">
|
||||
<label className="flex items-center gap-3 cursor-pointer flex-1 select-none">
|
||||
<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 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>
|
||||
|
||||
<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-500 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 select-none">
|
||||
<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>
|
||||
|
||||
<div className="pt-3.5 border-t border-ink-100 flex justify-end gap-3 flex-shrink-0 mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-lg 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-5 py-2 rounded-lg 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>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,410 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, UploadCloud, Eye, FileText, File } from 'lucide-react';
|
||||
import { uploadAsset } from '../../../services/assets-api';
|
||||
|
||||
interface UploadAssetModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess
|
||||
}) => {
|
||||
const [uploadTab, setUploadTab] = useState<'file' | 'url'>('file');
|
||||
const [uploadFile, setUploadFile] = useState<File | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [fullImagePreviewUrl, setFullImagePreviewUrl] = useState<string | 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);
|
||||
|
||||
useEffect(() => {
|
||||
if (!uploadFile) {
|
||||
setPreviewUrl(null);
|
||||
setFullImagePreviewUrl(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (uploadFile.type.startsWith('image/')) {
|
||||
const url = URL.createObjectURL(uploadFile);
|
||||
setPreviewUrl(url);
|
||||
return () => {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
} else {
|
||||
setPreviewUrl(null);
|
||||
setFullImagePreviewUrl(null);
|
||||
}
|
||||
}, [uploadFile]);
|
||||
|
||||
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 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 uploadAsset(formData);
|
||||
setUploadFile(null);
|
||||
setUploadUrl('');
|
||||
setUploadTitle('');
|
||||
setUploadDescription('');
|
||||
setUploadCategory('Marketing');
|
||||
setUploadSubcategory('');
|
||||
setUploadTags('');
|
||||
setUploadGithubUrl('');
|
||||
setUploadIsDownloadable(true);
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error('Failed to upload asset', err);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<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={onClose}
|
||||
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-2xl p-6 max-w-lg w-full max-h-[90vh] shadow-xl z-10 flex flex-col overflow-hidden"
|
||||
>
|
||||
<div className="flex justify-between items-center pb-3.5 border-b border-ink-100 flex-shrink-0">
|
||||
<h3 className="text-lg font-bold text-ink-900">Upload / Link Asset</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
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 rounded-xl border border-ink-200 flex-shrink-0 mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUploadTab('file')}
|
||||
className={`flex-1 py-1.5 text-xs font-semibold rounded-lg transition-all ${uploadTab === 'file' ? 'bg-ink-0 text-ink-900 shadow-sm border border-ink-200' : 'text-ink-500 hover:text-ink-800'}`}
|
||||
>
|
||||
Secure File Upload
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUploadTab('url')}
|
||||
className={`flex-1 py-1.5 text-xs font-semibold rounded-lg transition-all ${uploadTab === 'url' ? 'bg-ink-0 text-ink-900 shadow-sm border border-ink-200' : 'text-ink-500 hover:text-ink-800'}`}
|
||||
>
|
||||
External Web URL
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleUploadSubmit} className="flex-1 min-h-0 flex flex-col mt-4">
|
||||
<div className="flex-1 overflow-y-auto pr-1 space-y-4 scrollbar-thin">
|
||||
{uploadTab === 'file' ? (
|
||||
<div className={`border-2 border-dashed border-ink-200 hover:border-ink-400 rounded-xl p-5 text-center transition-colors relative bg-ink-50 max-h-48 overflow-y-auto scrollbar-thin ${!uploadFile ? 'cursor-pointer' : ''}`}>
|
||||
{!uploadFile && (
|
||||
<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 z-10"
|
||||
/>
|
||||
)}
|
||||
{uploadFile && !previewUrl && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setUploadFile(null);
|
||||
}}
|
||||
className="absolute top-2 right-2 p-1 rounded-lg bg-ink-0 hover:bg-ink-100 text-ink-500 hover:text-ink-900 border border-ink-200 transition-colors z-20 shadow-sm"
|
||||
title="Remove file"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{previewUrl && uploadFile ? (
|
||||
<div className="relative z-20 py-1">
|
||||
<div className="relative w-24 h-24 mx-auto mb-3">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Upload preview"
|
||||
className="w-full h-full object-cover rounded-lg shadow-sm border border-ink-200"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 max-w-xs mx-auto px-2.5 py-1.5 bg-ink-0 border border-ink-200 rounded-lg shadow-sm relative z-30 mb-1.5">
|
||||
<span className="text-[11px] font-semibold text-ink-900 truncate flex-1 text-left" title={uploadFile?.name}>
|
||||
{uploadFile?.name}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setFullImagePreviewUrl(previewUrl);
|
||||
}}
|
||||
className="p-1 rounded-lg bg-ink-55 hover:bg-ink-100 text-ink-600 hover:text-ink-950 border border-ink-200 transition-all duration-200 hover:scale-105 active:scale-95 flex items-center justify-center"
|
||||
title="Preview Image"
|
||||
>
|
||||
<Eye className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setUploadFile(null);
|
||||
}}
|
||||
className="p-1 rounded-lg bg-ink-55 hover:bg-red-500/10 text-ink-500 hover:text-red-650 border border-ink-200 transition-all duration-200 hover:scale-105 active:scale-95 flex items-center justify-center"
|
||||
title="Remove File"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-ink-500">
|
||||
{formatBytes(uploadFile?.size || 0)}
|
||||
</p>
|
||||
</div>
|
||||
) : uploadFile ? (
|
||||
<div className="relative z-0 py-1">
|
||||
<div className="w-12 h-12 rounded-lg bg-ink-100 border border-ink-200 flex items-center justify-center mx-auto mb-2">
|
||||
{uploadFile?.name?.endsWith('.pdf') ? (
|
||||
<FileText className="w-6 h-6 text-ink-600" />
|
||||
) : (
|
||||
<File className="w-6 h-6 text-ink-600" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs font-bold text-ink-900 truncate max-w-xs mx-auto">
|
||||
{uploadFile?.name}
|
||||
</p>
|
||||
<p className="text-[10px] text-ink-500 mt-0.5">
|
||||
{formatBytes(uploadFile?.size || 0)}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative z-0">
|
||||
<UploadCloud className="w-8 h-8 text-ink-400 mx-auto mb-1.5" />
|
||||
<p className="text-xs font-bold text-ink-900">
|
||||
Drag & drop or click to upload file
|
||||
</p>
|
||||
<p className="text-[10px] text-ink-500 mt-0.5">PDF, ZIP, PNG, JPG up to 50MB</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label className="text-xs font-semibold 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-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-semibold 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-lg px-3 py-2 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-xs font-semibold 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-lg px-3 py-2 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-xs font-semibold 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-lg px-3 py-2 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-xs font-semibold 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-lg px-3 py-2 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-lg">
|
||||
<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-500">
|
||||
If unchecked, clients must request manual download access (Strict View Only).
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-semibold 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-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-semibold 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-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-3.5 border-t border-ink-100 flex justify-end gap-3 flex-shrink-0 mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-lg 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-5 py-2 rounded-lg 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>
|
||||
|
||||
{/* Full Local Image Preview Modal */}
|
||||
<AnimatePresence>
|
||||
{fullImagePreviewUrl && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setFullImagePreviewUrl(null)}
|
||||
className="absolute inset-0 bg-ink-950/85 backdrop-blur-md"
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
className="relative max-w-[95vw] max-h-[90vh] z-10 flex flex-col items-center justify-center"
|
||||
>
|
||||
<button
|
||||
onClick={() => setFullImagePreviewUrl(null)}
|
||||
className="absolute -top-10 right-0 p-1.5 rounded-lg bg-ink-0 hover:bg-ink-100 text-ink-900 border border-ink-200 shadow-lg transition-colors z-20"
|
||||
title="Close"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
<img
|
||||
src={fullImagePreviewUrl}
|
||||
alt="Full preview"
|
||||
className="max-w-full max-h-[80vh] object-contain rounded-xl shadow-2xl border border-ink-200 bg-ink-50"
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
};
|
||||
21
Channel-Frontend/src/hooks/use-legal-mutation.ts
Normal file
21
Channel-Frontend/src/hooks/use-legal-mutation.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { UseMutationResult } from "@tanstack/react-query";
|
||||
import { approvePartnerAccess } from "../services/legal-api";
|
||||
import { LEGAL_QUERY_KEYS } from "./use-legal-query";
|
||||
|
||||
export const useApprovePartnerMutation = (): UseMutationResult<
|
||||
void,
|
||||
Error,
|
||||
string
|
||||
> => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<void, Error, string>({
|
||||
mutationFn: (partnerId: string) => approvePartnerAccess(partnerId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: LEGAL_QUERY_KEYS.pending(),
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
19
Channel-Frontend/src/hooks/use-legal-query.ts
Normal file
19
Channel-Frontend/src/hooks/use-legal-query.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { UseQueryResult } from "@tanstack/react-query";
|
||||
import { getPendingPartners } from "../services/legal-api";
|
||||
import type { PendingPartner } from "../services/legal-api";
|
||||
|
||||
export const LEGAL_QUERY_KEYS = {
|
||||
pending: () => ["legal", "pending"] as const,
|
||||
};
|
||||
|
||||
export const usePendingPartnersQuery = (
|
||||
enabled: boolean = true
|
||||
): UseQueryResult<PendingPartner[]> => {
|
||||
return useQuery<PendingPartner[]>({
|
||||
queryKey: LEGAL_QUERY_KEYS.pending(),
|
||||
queryFn: getPendingPartners,
|
||||
enabled,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes stale time
|
||||
});
|
||||
};
|
||||
22
Channel-Frontend/src/hooks/use-partners-mutation.ts
Normal file
22
Channel-Frontend/src/hooks/use-partners-mutation.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { UseMutationResult } from "@tanstack/react-query";
|
||||
import { invitePartner } from "../services/auth-api";
|
||||
import type { InviteResponse } from "../services/auth-api";
|
||||
import { PARTNERS_QUERY_KEYS } from "./use-partners-query";
|
||||
|
||||
export const useInvitePartnerMutation = (): UseMutationResult<
|
||||
InviteResponse,
|
||||
Error,
|
||||
string
|
||||
> => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<InviteResponse, Error, string>({
|
||||
mutationFn: (email: string) => invitePartner(email),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: PARTNERS_QUERY_KEYS.list(),
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
19
Channel-Frontend/src/hooks/use-partners-query.ts
Normal file
19
Channel-Frontend/src/hooks/use-partners-query.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { UseQueryResult } from "@tanstack/react-query";
|
||||
import { getPartners } from "../services/auth-api";
|
||||
import type { Partner } from "../services/auth-api";
|
||||
|
||||
export const PARTNERS_QUERY_KEYS = {
|
||||
list: () => ["partners", "list"] as const,
|
||||
};
|
||||
|
||||
export const usePartnersQuery = (
|
||||
enabled: boolean = true
|
||||
): UseQueryResult<Partner[]> => {
|
||||
return useQuery<Partner[]>({
|
||||
queryKey: PARTNERS_QUERY_KEYS.list(),
|
||||
queryFn: getPartners,
|
||||
enabled,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes stale time
|
||||
});
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@ -3,6 +3,7 @@ import { motion } from 'framer-motion';
|
||||
import type { Variants } from 'framer-motion';
|
||||
import { FolderKanban, FileSignature, Users, ArrowUpRight, Activity, Zap, ShieldCheck } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { PageHeader } from '../components/ui/PageHeader';
|
||||
|
||||
const containerVariants: Variants = {
|
||||
hidden: { opacity: 0 },
|
||||
@ -57,63 +58,61 @@ export const DashboardPage = () => {
|
||||
];
|
||||
|
||||
return (
|
||||
<motion.div variants={containerVariants} initial="hidden" animate="show" className="max-w-[1400px] mx-auto w-full space-y-10 text-ink-900 animate-fade-in">
|
||||
<motion.div variants={itemVariants} className="flex flex-col gap-3">
|
||||
<div className="inline-flex items-center gap-2.5 px-4 py-1.5 rounded-full bg-ink-900 border border-ink-800 w-fit shadow-sm text-ink-0">
|
||||
<div className="w-2 h-2 rounded-full bg-ink-0 animate-pulse" />
|
||||
<span className="text-[11px] font-bold tracking-widest uppercase">System Active</span>
|
||||
</div>
|
||||
<h1 className="text-5xl font-extrabold text-ink-900 tracking-tight mt-6">
|
||||
Welcome back, {user?.email?.split('@')[0]}
|
||||
</h1>
|
||||
<p className="text-xl text-ink-500 max-w-2xl leading-relaxed mt-2 font-medium">
|
||||
You are authenticated as <span className="text-ink-900 font-extrabold uppercase tracking-wider">{user?.role}</span>. Manage your channel network, monitor compliance, and distribute assets globally.
|
||||
</p>
|
||||
</motion.div>
|
||||
<motion.div variants={containerVariants} initial="hidden" animate="show" className="w-full space-y-6 text-ink-900 animate-fade-in">
|
||||
<PageHeader
|
||||
title={`Welcome back, ${user?.email?.split('@')[0]}`}
|
||||
subtitle={`You are authenticated as ${user?.role}. Manage your channel network, monitor compliance, and distribute assets globally.`}
|
||||
badge={
|
||||
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-ink-900 border border-ink-800 text-[10px] font-bold text-ink-0 tracking-wider uppercase shrink-0">
|
||||
<div className="w-1 h-1 rounded-full bg-ink-0 animate-pulse" />
|
||||
<span>System Active</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-3 gap-6 pt-6">
|
||||
<motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-2">
|
||||
{[
|
||||
{ label: 'Global Network Uptime', value: '99.99%', icon: Activity, trend: '+0.01%' },
|
||||
{ label: 'Active Data Streams', value: '1,492', icon: Zap, trend: '+12%' },
|
||||
{ label: 'Security Compliance', value: 'Level 4', icon: ShieldCheck, trend: 'Verified' }
|
||||
].map((stat, i) => (
|
||||
<div key={i} className="relative group overflow-hidden rounded-3xl bg-ink-0 border border-ink-200 p-8 transition-all duration-300 shadow-sm hover:shadow-md">
|
||||
<div className="absolute top-0 right-0 p-6 opacity-5 group-hover:opacity-10 transition-opacity duration-500 group-hover:scale-110 transform">
|
||||
<stat.icon className="w-24 h-24 text-ink-900" />
|
||||
<div key={i} className="relative group overflow-hidden rounded-xl bg-ink-0 border border-ink-200 p-4 transition-all duration-300 shadow-sm hover:shadow-md">
|
||||
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity duration-500 group-hover:scale-110 transform">
|
||||
<stat.icon className="w-16 h-16 text-ink-900" />
|
||||
</div>
|
||||
<p className="text-sm font-bold uppercase tracking-wider text-ink-500 mb-2">{stat.label}</p>
|
||||
<div className="flex items-end gap-4 mt-4">
|
||||
<h3 className="text-5xl font-extrabold text-ink-900 tracking-tighter">{stat.value}</h3>
|
||||
<span className="text-xs font-bold text-ink-900 bg-ink-100 px-2.5 py-1.5 rounded-lg mb-1.5 border border-ink-200">{stat.trend}</span>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-ink-500 mb-1">{stat.label}</p>
|
||||
<div className="flex items-end gap-3 mt-2">
|
||||
<h3 className="text-xl font-bold text-ink-900 tracking-tight">{stat.value}</h3>
|
||||
<span className="text-xs font-bold text-ink-900 bg-ink-100 px-2 py-0.5 rounded-md mb-0.5 border border-ink-200">{stat.trend}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* Main Action Cards */}
|
||||
<motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-3 gap-8 pt-6">
|
||||
<motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-2">
|
||||
{CARDS.map((card, idx) => (
|
||||
<Link key={idx} to={card.path} className="group relative block h-full">
|
||||
<div className="relative h-full bg-ink-0 border border-ink-200 rounded-[2rem] p-10 hover:border-ink-400 transition-all duration-300 overflow-hidden shadow-sm hover:shadow-md hover:-translate-y-1 flex flex-col justify-between">
|
||||
<div className="relative h-full bg-ink-0 border border-ink-200 rounded-xl p-5 hover:border-ink-400 transition-all duration-300 overflow-hidden shadow-sm hover:shadow-md hover:-translate-y-0.5 flex flex-col justify-between">
|
||||
|
||||
<div className="flex justify-between items-start mb-16 relative z-10">
|
||||
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-ink-900 to-ink-800 shadow-lg group-hover:scale-105 transition-all duration-350 flex items-center justify-center text-ink-0">
|
||||
<card.icon className="w-8 h-8" />
|
||||
<div className="flex justify-between items-start mb-8 relative z-10">
|
||||
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-ink-900 to-ink-800 shadow-sm group-hover:scale-105 transition-all duration-350 flex items-center justify-center text-ink-0">
|
||||
<card.icon className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-full bg-ink-50 flex items-center justify-center group-hover:bg-ink-100 transition-all duration-300 border border-ink-200 group-hover:border-ink-300">
|
||||
<ArrowUpRight className="w-6 h-6 text-ink-400 group-hover:text-ink-900 transition-colors" />
|
||||
<div className="w-8 h-8 rounded-full bg-ink-50 flex items-center justify-center group-hover:bg-ink-100 transition-all duration-300 border border-ink-200 group-hover:border-ink-300">
|
||||
<ArrowUpRight className="w-4 h-4 text-ink-400 group-hover:text-ink-900 transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 mt-auto">
|
||||
<div className="inline-block px-3.5 py-1.5 rounded-full bg-ink-50 border border-ink-200 text-xs font-bold text-ink-500 mb-5 shadow-sm">
|
||||
<div className="inline-block px-2 py-0.5 rounded-md bg-ink-50 border border-ink-200 text-xs font-bold text-ink-500 mb-3 shadow-sm">
|
||||
{card.metrics}
|
||||
</div>
|
||||
<h3 className="text-3xl font-extrabold text-ink-900 mb-4 tracking-tight">
|
||||
<h3 className="text-lg font-bold text-ink-900 mb-2 tracking-tight">
|
||||
{card.title}
|
||||
</h3>
|
||||
<p className="text-ink-500 leading-relaxed text-sm font-medium">
|
||||
<p className="text-ink-500 leading-normal text-xs font-medium">
|
||||
{card.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -5,7 +5,7 @@ import { loginUser } from '../services/auth-api';
|
||||
import { useAuthStore } from '../hooks/use-auth';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useState } from 'react';
|
||||
import { Hexagon, Lock, Mail, ArrowRight } from 'lucide-react';
|
||||
import { Hexagon, Lock, Mail, ArrowRight, Eye, EyeOff } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
const loginSchema = z.object({
|
||||
@ -17,6 +17,7 @@ type LoginFormValues = z.infer<typeof loginSchema>;
|
||||
|
||||
export const LoginPage = () => {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const setAuth = useAuthStore((state) => state.setAuth);
|
||||
const navigate = useNavigate();
|
||||
|
||||
@ -50,7 +51,7 @@ export const LoginPage = () => {
|
||||
<div className="absolute bottom-1/4 right-1/4 w-[600px] h-[600px] bg-ink-900/5 rounded-full blur-[150px] pointer-events-none" />
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 1, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="w-full max-w-md relative z-10"
|
||||
@ -100,11 +101,18 @@ export const LoginPage = () => {
|
||||
<Lock className={`w-5 h-5 transition-colors ${errors.password ? 'text-red-400' : 'text-ink-400 group-focus-within:text-ink-900'}`} />
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
{...register('password')}
|
||||
className={`w-full bg-ink-50 border ${errors.password ? 'border-red-500/50 focus:border-red-500' : 'border-ink-200 focus:border-ink-900/50'} rounded-2xl py-4 pl-12 pr-4 text-ink-900 placeholder-ink-400 outline-none transition-all focus:bg-ink-0 focus:ring-4 ring-ink-900/10 font-medium`}
|
||||
className={`w-full bg-ink-50 border ${errors.password ? 'border-red-500/50 focus:border-red-500' : 'border-ink-200 focus:border-ink-900/50'} rounded-2xl py-4 pl-12 pr-12 text-ink-900 placeholder-ink-400 outline-none transition-all focus:bg-ink-0 focus:ring-4 ring-ink-900/10 font-medium`}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute inset-y-0 right-0 pr-4 flex items-center text-ink-400 hover:text-ink-950 focus:outline-none transition-colors"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && <p className="text-red-500 dark:text-red-400 text-xs mt-1.5 font-bold">{errors.password.message}</p>}
|
||||
</div>
|
||||
|
||||
@ -4,7 +4,12 @@ import { ShieldCheck, CheckCircle, ChevronRight, Lock, UploadCloud, FileText, Cl
|
||||
import { useAuthStore } from '../hooks/use-auth';
|
||||
import { SignatureCapture } from '../features/agreements/components/SignatureCapture';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { axiosInstance } from '../services/axios';
|
||||
import {
|
||||
getActiveLegalDocument,
|
||||
getMyAcceptances,
|
||||
uploadSignedLegalDocument,
|
||||
signLegalDocument
|
||||
} from '../services/legal-api';
|
||||
|
||||
type DocumentType = 'NDA' | 'MSA';
|
||||
|
||||
@ -35,14 +40,14 @@ export const OnboardingPage: React.FC = () => {
|
||||
useEffect(() => {
|
||||
const fetchDocs = async () => {
|
||||
try {
|
||||
const [ndaRes, msaRes] = await Promise.all([
|
||||
axiosInstance.get('/legal/documents/active/NDA'),
|
||||
axiosInstance.get('/legal/documents/active/MSA')
|
||||
const [ndaData, msaData] = await Promise.all([
|
||||
getActiveLegalDocument('NDA'),
|
||||
getActiveLegalDocument('MSA')
|
||||
]);
|
||||
setNdaContent(ndaRes.data.content);
|
||||
setNdaPdfUrl(ndaRes.data.pdfUrl || null);
|
||||
setMsaContent(msaRes.data.content);
|
||||
setMsaPdfUrl(msaRes.data.pdfUrl || null);
|
||||
setNdaContent(ndaData.content);
|
||||
setNdaPdfUrl(ndaData.pdfUrl || null);
|
||||
setMsaContent(msaData.content);
|
||||
setMsaPdfUrl(msaData.pdfUrl || null);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch legal documents:', err);
|
||||
} finally {
|
||||
@ -145,9 +150,9 @@ export const OnboardingPage: React.FC = () => {
|
||||
setStep(3); // Go straight to pending screen
|
||||
} else {
|
||||
// Check existing acceptances to skip steps
|
||||
axiosInstance.get('/legal/my-acceptances').then(res => {
|
||||
const hasNDA = res.data.some((a: any) => a.document.type === 'NDA');
|
||||
const hasMSA = res.data.some((a: any) => a.document.type === 'MSA');
|
||||
getMyAcceptances().then(data => {
|
||||
const hasNDA = data.some((a: any) => a.document.type === 'NDA');
|
||||
const hasMSA = data.some((a: any) => a.document.type === 'MSA');
|
||||
if (hasNDA && !hasMSA) setStep(2);
|
||||
if (hasNDA && hasMSA) setStep(3);
|
||||
}).catch(console.error);
|
||||
@ -182,12 +187,10 @@ export const OnboardingPage: React.FC = () => {
|
||||
formData.append('file', file);
|
||||
formData.append('title', `Signed ${type} - ${user?.email}`);
|
||||
|
||||
const response = await axiosInstance.post('/legal/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
const data = await uploadSignedLegalDocument(formData);
|
||||
|
||||
if (type === 'NDA') setNdaUploadUrl(response.data.url);
|
||||
if (type === 'MSA') setMsaUploadUrl(response.data.url);
|
||||
if (type === 'NDA') setNdaUploadUrl(data.url);
|
||||
if (type === 'MSA') setMsaUploadUrl(data.url);
|
||||
} catch (error) {
|
||||
console.error(`Failed to upload ${type}:`, error);
|
||||
} finally {
|
||||
@ -201,7 +204,7 @@ export const OnboardingPage: React.FC = () => {
|
||||
const signatureBase64 = type === 'NDA' ? ndaSignature : msaSignature;
|
||||
const documentUrl = type === 'NDA' ? ndaUploadUrl : msaUploadUrl;
|
||||
|
||||
await axiosInstance.post('/legal/sign', {
|
||||
await signLegalDocument({
|
||||
documentType: type,
|
||||
signatureBase64,
|
||||
documentUrl
|
||||
@ -225,13 +228,13 @@ export const OnboardingPage: React.FC = () => {
|
||||
<div className="flex bg-ink-100 rounded-xl p-1 mb-6 max-w-sm">
|
||||
<button
|
||||
onClick={() => setMode('draw')}
|
||||
className={`flex-1 py-2 text-sm font-bold rounded-lg transition-all ${mode === 'draw' ? 'bg-ink-0 shadow text-ink-900' : 'text-ink-500 hover:text-ink-900'}`}
|
||||
className={`flex-1 py-2 text-sm font-bold rounded-lg transition-all ${mode === 'draw' ? 'bg-ink-0 shadow text-ink-900 border border-ink-200' : 'text-ink-500 hover:text-ink-900'}`}
|
||||
>
|
||||
Draw Signature
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode('upload')}
|
||||
className={`flex-1 py-2 text-sm font-bold rounded-lg transition-all ${mode === 'upload' ? 'bg-ink-0 shadow text-ink-900' : 'text-ink-500 hover:text-ink-900'}`}
|
||||
className={`flex-1 py-2 text-sm font-bold rounded-lg transition-all ${mode === 'upload' ? 'bg-ink-0 shadow text-ink-900 border border-ink-200' : 'text-ink-500 hover:text-ink-900'}`}
|
||||
>
|
||||
Upload PDF
|
||||
</button>
|
||||
@ -460,4 +463,5 @@ export const OnboardingPage: React.FC = () => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OnboardingPage;
|
||||
|
||||
@ -1,56 +1,19 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React from 'react';
|
||||
import { CheckCircle, Clock, Search, XCircle } from 'lucide-react';
|
||||
import { axiosInstance } from '../../services/axios';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
interface PendingPartner {
|
||||
id: string;
|
||||
email: string;
|
||||
createdAt: string;
|
||||
acceptances: Array<{
|
||||
id: string;
|
||||
signatureHash: string | null;
|
||||
documentUrl: string | null;
|
||||
document: {
|
||||
type: string;
|
||||
version: string;
|
||||
}
|
||||
}>;
|
||||
}
|
||||
import { usePendingPartnersQuery } from '../../hooks/use-legal-query';
|
||||
import { useApprovePartnerMutation } from '../../hooks/use-legal-mutation';
|
||||
import PageHeader from '../../components/ui/PageHeader';
|
||||
|
||||
export const ApprovalsPage: React.FC = () => {
|
||||
const [partners, setPartners] = useState<PendingPartner[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [processingId, setProcessingId] = useState<string | null>(null);
|
||||
const { data: partners = [], isLoading } = usePendingPartnersQuery();
|
||||
const approveMutation = useApprovePartnerMutation();
|
||||
|
||||
useEffect(() => {
|
||||
fetchPending();
|
||||
}, []);
|
||||
|
||||
const fetchPending = async () => {
|
||||
try {
|
||||
const res = await axiosInstance.get('/legal/pending');
|
||||
setPartners(res.data);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch pending partners', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
const approvePartner = (partnerId: string) => {
|
||||
approveMutation.mutate(partnerId);
|
||||
};
|
||||
|
||||
const approvePartner = async (partnerId: string) => {
|
||||
setProcessingId(partnerId);
|
||||
try {
|
||||
await axiosInstance.post(`/legal/approve/${partnerId}`);
|
||||
setPartners(partners.filter(p => p.id !== partnerId));
|
||||
} catch (err) {
|
||||
console.error('Failed to approve', err);
|
||||
} finally {
|
||||
setProcessingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center min-h-[60vh]">
|
||||
<div className="w-8 h-8 border-4 border-ink-900/30 border-t-ink-900 rounded-full animate-spin" />
|
||||
@ -59,52 +22,48 @@ export const ApprovalsPage: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto space-y-8 animate-fade-in text-ink-900">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-extrabold text-ink-900 tracking-tight flex items-center gap-3">
|
||||
Approvals Queue
|
||||
<span className="bg-ink-900 text-ink-0 text-xs px-2.5 py-1 rounded-full font-bold border border-ink-700 shadow-sm">
|
||||
{partners.length} Pending
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-ink-500 text-sm mt-1">
|
||||
Review and approve partner legal documents to grant platform access.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="w-4 h-4 text-ink-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search pending partners..."
|
||||
className="pl-9 pr-4 py-2 bg-ink-0 border border-ink-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400 w-full sm:w-64"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full space-y-6 animate-fade-in text-ink-900">
|
||||
<PageHeader
|
||||
title="Approvals Queue"
|
||||
subtitle="Review and approve partner legal documents to grant platform access."
|
||||
badge={
|
||||
<span className="bg-ink-900 text-ink-0 text-[10px] px-2 py-0.5 rounded-full font-bold border border-ink-700 shadow-sm uppercase tracking-wider shrink-0">
|
||||
{partners.length} Pending
|
||||
</span>
|
||||
}
|
||||
actions={
|
||||
<div className="relative">
|
||||
<Search className="w-4 h-4 text-ink-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search pending partners..."
|
||||
className="pl-9 pr-4 py-1.5 bg-ink-0 border border-ink-200 rounded-lg text-xs focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400 w-full sm:w-64 shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* List */}
|
||||
<div className="bg-ink-0 rounded-2xl border border-ink-200 overflow-hidden shadow-sm">
|
||||
<div className="bg-ink-0 rounded-xl border border-ink-200 overflow-hidden shadow-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm whitespace-nowrap">
|
||||
<thead className="bg-ink-50 border-b border-ink-200 text-ink-500 font-bold uppercase tracking-wider text-xs">
|
||||
<tr>
|
||||
<th className="px-6 py-4">Partner</th>
|
||||
<th className="px-6 py-4">NDA Status</th>
|
||||
<th className="px-6 py-4">MSA Status</th>
|
||||
<th className="px-6 py-4 text-right">Actions</th>
|
||||
<th className="px-4 py-2.5">Partner</th>
|
||||
<th className="px-4 py-2.5">NDA Status</th>
|
||||
<th className="px-4 py-2.5">MSA Status</th>
|
||||
<th className="px-4 py-2.5 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink-200">
|
||||
<AnimatePresence>
|
||||
{partners.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-6 py-12 text-center">
|
||||
<td colSpan={4} className="px-4 py-8 text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-ink-100 flex items-center justify-center mx-auto mb-4">
|
||||
<CheckCircle className="w-6 h-6 text-ink-900" />
|
||||
</div>
|
||||
<p className="text-ink-900 font-bold">Queue is empty</p>
|
||||
<p className="text-ink-900 font-bold text-sm">Queue is empty</p>
|
||||
<p className="text-ink-500 text-xs mt-1">All partners have been reviewed.</p>
|
||||
</td>
|
||||
</tr>
|
||||
@ -120,13 +79,13 @@ export const ApprovalsPage: React.FC = () => {
|
||||
exit={{ opacity: 0, x: -20, backgroundColor: 'rgba(0, 0, 0, 0.02)' }}
|
||||
className="hover:bg-ink-50 transition-colors group"
|
||||
>
|
||||
<td className="px-6 py-4">
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-ink-900 to-ink-800 flex items-center justify-center text-ink-0 font-bold text-xs shadow-md">
|
||||
{partner.email.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-bold text-ink-900">{partner.email}</p>
|
||||
<p className="font-bold text-ink-900 text-sm">{partner.email}</p>
|
||||
<p className="text-xs text-ink-500 flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{new Date(partner.createdAt).toLocaleDateString()}
|
||||
@ -134,11 +93,11 @@ export const ApprovalsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<td className="px-4 py-3">
|
||||
{nda ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-ink-900" />
|
||||
<span className="text-xs font-bold text-ink-900 bg-ink-100 border border-ink-200 px-2 py-1 rounded-md">
|
||||
<span className="text-xs font-bold text-ink-900 bg-ink-100 border border-ink-200 px-2 py-0.5 rounded-md">
|
||||
{nda.documentUrl ? 'Uploaded PDF' : 'Digital Sign'}
|
||||
</span>
|
||||
{nda.documentUrl && (
|
||||
@ -148,15 +107,15 @@ export const ApprovalsPage: React.FC = () => {
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-ink-400">
|
||||
<XCircle className="w-4 h-4" />
|
||||
<span className="text-xs font-bold bg-ink-50 border border-ink-200 text-ink-400 px-2 py-1 rounded-md">Missing</span>
|
||||
<span className="text-xs font-bold bg-ink-50 border border-ink-200 text-ink-400 px-2 py-0.5 rounded-md">Missing</span>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<td className="px-4 py-3">
|
||||
{msa ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-ink-900" />
|
||||
<span className="text-xs font-bold text-ink-900 bg-ink-100 border border-ink-200 px-2 py-1 rounded-md">
|
||||
<span className="text-xs font-bold text-ink-900 bg-ink-100 border border-ink-200 px-2 py-0.5 rounded-md">
|
||||
{msa.documentUrl ? 'Uploaded PDF' : 'Digital Sign'}
|
||||
</span>
|
||||
{msa.documentUrl && (
|
||||
@ -166,17 +125,17 @@ export const ApprovalsPage: React.FC = () => {
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-ink-400">
|
||||
<XCircle className="w-4 h-4" />
|
||||
<span className="text-xs font-bold bg-ink-50 border border-ink-200 text-ink-400 px-2 py-1 rounded-md">Missing</span>
|
||||
<span className="text-xs font-bold bg-ink-50 border border-ink-200 text-ink-400 px-2 py-0.5 rounded-md">Missing</span>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={() => approvePartner(partner.id)}
|
||||
disabled={!nda || !msa || processingId === partner.id}
|
||||
className="px-4 py-2 bg-ink-900 text-ink-0 text-xs font-bold rounded-lg hover:bg-ink-800 transition-colors shadow-md disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={!nda || !msa || (approveMutation.isPending && approveMutation.variables === partner.id)}
|
||||
className="px-3 py-1.5 bg-ink-900 text-ink-0 text-xs font-bold rounded-lg hover:bg-ink-800 transition-colors shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{processingId === partner.id ? 'Approving...' : 'Approve Access'}
|
||||
{approveMutation.isPending && approveMutation.variables === partner.id ? 'Approving...' : 'Approve Access'}
|
||||
</button>
|
||||
</td>
|
||||
</motion.tr>
|
||||
|
||||
@ -1,16 +1,10 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { Users, Mail, CheckCircle, AlertCircle, ChevronRight, UserPlus, Clock, ShieldCheck, RefreshCw } from 'lucide-react';
|
||||
import { axiosInstance } from '../../services/axios';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
interface Partner {
|
||||
id: string;
|
||||
email: string;
|
||||
onboardingStatus: string;
|
||||
mfaEnabled: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
import { usePartnersQuery } from '../../hooks/use-partners-query';
|
||||
import { useInvitePartnerMutation } from '../../hooks/use-partners-mutation';
|
||||
import PageHeader from '../../components/ui/PageHeader';
|
||||
|
||||
const STATUS_CONFIG: Record<string, { label: string; color: string; bg: string; border: string }> = {
|
||||
PENDING_ONBOARDING: {
|
||||
@ -42,42 +36,27 @@ const getStatusConfig = (status: string) => STATUS_CONFIG[status] ?? {
|
||||
|
||||
export const DirectoryPage: React.FC = () => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [inviteResult, setInviteResult] = useState<{ token?: string; error?: string } | null>(null);
|
||||
const [partners, setPartners] = useState<Partner[]>([]);
|
||||
const [loadingPartners, setLoadingPartners] = useState(true);
|
||||
const [isInviteOpen, setIsInviteOpen] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const ITEMS_PER_PAGE = 5;
|
||||
|
||||
const fetchPartners = async () => {
|
||||
setLoadingPartners(true);
|
||||
try {
|
||||
const res = await axiosInstance.get('/auth/partners');
|
||||
setPartners(res.data);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch partners', err);
|
||||
} finally {
|
||||
setLoadingPartners(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchPartners();
|
||||
}, []);
|
||||
const { data: partners = [], isLoading: loadingPartners, refetch: fetchPartners } = usePartnersQuery();
|
||||
const inviteMutation = useInvitePartnerMutation();
|
||||
|
||||
const handleInvite = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
setInviteResult(null);
|
||||
|
||||
try {
|
||||
const res = await axiosInstance.post('/auth/invite', { email });
|
||||
setInviteResult({ token: res.data.token });
|
||||
setEmail('');
|
||||
fetchPartners();
|
||||
} catch (err: any) {
|
||||
setInviteResult({ error: err.response?.data?.error || 'Failed to send invite' });
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
inviteMutation.mutate(email, {
|
||||
onSuccess: (data) => {
|
||||
setInviteResult({ token: data.token });
|
||||
setEmail('');
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setInviteResult({ error: err.response?.data?.error || 'Failed to send invite' });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const counts = {
|
||||
@ -102,47 +81,61 @@ export const DirectoryPage: React.FC = () => {
|
||||
});
|
||||
}
|
||||
|
||||
const totalItems = partners.length;
|
||||
const totalPages = Math.ceil(totalItems / ITEMS_PER_PAGE) || 1;
|
||||
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
|
||||
const endIndex = Math.min(startIndex + ITEMS_PER_PAGE, totalItems);
|
||||
const paginatedPartners = partners.slice(startIndex, endIndex);
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto space-y-8 animate-fade-in text-ink-900">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-extrabold text-ink-900 tracking-tight">
|
||||
Partner Directory
|
||||
</h1>
|
||||
<p className="text-ink-500 text-sm mt-1">
|
||||
Manage your network and invite new partners to the platform.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchPartners}
|
||||
className="p-2.5 rounded-xl bg-ink-0 border border-ink-200 text-ink-500 hover:text-ink-900 hover:shadow-md transition-all group"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 group-hover:rotate-180 transition-transform duration-500" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="w-full space-y-6 animate-fade-in text-ink-900">
|
||||
<PageHeader
|
||||
title="Partner Directory"
|
||||
subtitle="Manage your network and invite new partners to the platform."
|
||||
actions={
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
setInviteResult(null);
|
||||
setEmail('');
|
||||
setIsInviteOpen(true);
|
||||
}}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-ink-900 text-ink-0 text-xs font-bold hover:bg-ink-800 transition-all cursor-pointer shadow-sm"
|
||||
>
|
||||
<UserPlus className="w-4 h-4" />
|
||||
<span>Invite Partner</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { fetchPartners(); }}
|
||||
className="p-1.5 rounded-lg bg-ink-0 border border-ink-200 text-ink-500 hover:text-ink-900 hover:shadow-sm transition-all group cursor-pointer"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 group-hover:rotate-180 transition-transform duration-500" />
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Dynamic Real-time Approval Notification Banner */}
|
||||
{counts.awaitingApproval > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex items-center justify-between p-5 bg-ink-900 border border-ink-800 rounded-2xl shadow-sm text-ink-0 relative overflow-hidden"
|
||||
className="flex items-center justify-between p-4 bg-ink-900 border border-ink-800 rounded-xl shadow-sm text-ink-0 relative overflow-hidden"
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-ink-800 via-ink-900 to-ink-800 opacity-50" />
|
||||
<div className="flex items-center gap-3 relative z-10">
|
||||
<div className="w-10 h-10 rounded-xl bg-ink-0/10 flex items-center justify-center text-ink-0 shrink-0">
|
||||
<AlertCircle className="w-5 h-5 animate-bounce" />
|
||||
<div className="w-8 h-8 rounded-lg bg-ink-0/10 flex items-center justify-center text-ink-0 shrink-0">
|
||||
<AlertCircle className="w-4 h-4 animate-bounce" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-bold text-sm text-ink-0">Partner approvals pending</h4>
|
||||
<p className="text-xs text-ink-300 mt-0.5">There are {counts.awaitingApproval} partners awaiting document review and access authorization.</p>
|
||||
<h4 className="font-bold text-sm text-ink-0">Partner approvals pending</h4>
|
||||
<p className="text-xs text-ink-300 mt-0.5">There are {counts.awaitingApproval} partners awaiting document review and access authorization.</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
to="/admin/approvals"
|
||||
className="relative z-10 px-4 py-2 bg-ink-0 hover:bg-ink-100 text-ink-900 text-xs font-bold rounded-xl transition-all shadow-sm flex items-center gap-1.5 shrink-0"
|
||||
className="relative z-10 px-3 py-1.5 bg-ink-0 hover:bg-ink-100 text-ink-900 text-xs font-bold rounded-lg transition-all shadow-sm flex items-center gap-1.5 shrink-0"
|
||||
>
|
||||
Review Queue
|
||||
<ChevronRight className="w-3.5 h-3.5" />
|
||||
@ -153,153 +146,225 @@ export const DirectoryPage: React.FC = () => {
|
||||
{/* Stats Row */}
|
||||
<div className={`grid gap-4 ${counts.awaitingApproval > 0 ? 'grid-cols-2 md:grid-cols-4' : 'grid-cols-1 md:grid-cols-3'}`}>
|
||||
{statCards.map((stat, i) => (
|
||||
<div key={i} className={`bg-ink-0 rounded-2xl border p-5 shadow-sm transition-all duration-300 ${stat.accentClass || 'border-ink-200'}`}>
|
||||
<div key={i} className={`bg-ink-0 rounded-xl border p-4 shadow-sm transition-all duration-300 ${stat.accentClass || 'border-ink-200'}`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-ink-500">{stat.label}</p>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-ink-500">{stat.label}</p>
|
||||
<stat.icon className={`w-4 h-4 ${stat.accentClass ? 'text-ink-900 animate-pulse' : 'text-ink-400'}`} />
|
||||
</div>
|
||||
<p className={`text-3xl font-extrabold tracking-tight ${stat.accentClass ? 'text-ink-900' : 'text-ink-900'}`}>{stat.value}</p>
|
||||
<p className="text-xl font-bold tracking-tight text-ink-900">{stat.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Invite Form */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="bg-ink-0 rounded-2xl border border-ink-200 p-6 shadow-sm relative overflow-hidden sticky top-8">
|
||||
<div className="absolute top-0 right-0 p-4 opacity-5 pointer-events-none">
|
||||
<UserPlus className="w-24 h-24 text-ink-900" />
|
||||
</div>
|
||||
{/* Partner List Table (Full Width) */}
|
||||
<div className="bg-ink-0 rounded-xl border border-ink-200 shadow-sm overflow-hidden w-full">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm whitespace-nowrap">
|
||||
<thead className="bg-ink-50 border-b border-ink-200 text-ink-500 font-bold uppercase tracking-wider text-xs">
|
||||
<tr>
|
||||
<th className="px-4 py-2.5">Partner</th>
|
||||
<th className="px-4 py-2.5">Status</th>
|
||||
<th className="px-4 py-2.5">MFA</th>
|
||||
<th className="px-4 py-2.5">Joined</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink-200">
|
||||
{loadingPartners ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-8 text-center">
|
||||
<div className="w-6 h-6 border-4 border-ink-900/30 border-t-ink-900 rounded-full animate-spin mx-auto" />
|
||||
</td>
|
||||
</tr>
|
||||
) : partners.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-8 text-center">
|
||||
<div className="w-12 h-12 bg-ink-50 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<Users className="w-6 h-6 text-ink-400" />
|
||||
</div>
|
||||
<p className="text-sm font-bold text-ink-900">No partners yet</p>
|
||||
<p className="text-xs text-ink-500 mt-1">Use the invite button to add your first partner.</p>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
paginatedPartners.map(partner => {
|
||||
const sc = getStatusConfig(partner.onboardingStatus);
|
||||
return (
|
||||
<tr key={partner.id} className="hover:bg-ink-50 transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-ink-900 to-ink-800 flex items-center justify-center text-ink-0 font-bold text-xs shadow-md">
|
||||
{partner.email.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span className="font-bold text-ink-900 text-sm">{partner.email}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-md border ${sc.color} ${sc.bg} ${sc.border}`}>
|
||||
{sc.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{partner.mfaEnabled ? (
|
||||
<span className="text-xs font-bold text-ink-900">Enabled</span>
|
||||
) : (
|
||||
<span className="text-xs font-bold text-ink-400">Disabled</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-ink-500 font-medium">
|
||||
{new Date(partner.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-bold text-ink-900 mb-2 relative z-10">Invite Partner</h3>
|
||||
<p className="text-xs text-ink-500 mb-6 relative z-10">
|
||||
Generate a secure invitation link for a new partner.
|
||||
{/* Pagination Controls */}
|
||||
{totalItems > 0 && (
|
||||
<div className="px-4 py-3 border-t border-ink-200 bg-ink-50 flex flex-col sm:flex-row justify-between items-center gap-4 text-xs font-semibold text-ink-500">
|
||||
<p>
|
||||
Showing <span className="text-ink-900 font-extrabold">{startIndex + 1}-{endIndex}</span> of <span className="text-ink-900 font-extrabold">{totalItems}</span> partners
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="px-2 py-1 rounded-md border border-ink-200 bg-ink-0 hover:bg-ink-100 disabled:opacity-50 disabled:hover:bg-ink-0 transition-all font-bold text-ink-900 cursor-pointer"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
{Array.from({ length: totalPages }).map((_, idx) => {
|
||||
const pageNum = idx + 1;
|
||||
return (
|
||||
<button
|
||||
key={pageNum}
|
||||
onClick={() => setCurrentPage(pageNum)}
|
||||
className={`w-7 h-7 rounded-md flex items-center justify-center font-bold transition-all cursor-pointer ${
|
||||
currentPage === pageNum
|
||||
? 'bg-ink-900 text-ink-0 shadow-sm'
|
||||
: 'border border-ink-200 bg-ink-0 hover:bg-ink-100 text-ink-500 hover:text-ink-900'
|
||||
}`}
|
||||
>
|
||||
{pageNum}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
onClick={() => setCurrentPage(prev => Math.min(prev + 1, totalPages))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-2 py-1 rounded-md border border-ink-200 bg-ink-0 hover:bg-ink-100 disabled:opacity-50 disabled:hover:bg-ink-0 transition-all font-bold text-ink-900 cursor-pointer"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleInvite} className="space-y-4 relative z-10">
|
||||
<div>
|
||||
<label className="text-[10px] font-bold uppercase tracking-wider text-ink-500 mb-1.5 block">Email Address</label>
|
||||
<div className="relative">
|
||||
<Mail className="w-4 h-4 text-ink-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="partner@company.com"
|
||||
required
|
||||
className="w-full pl-9 pr-4 py-2.5 bg-ink-50 border border-ink-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400 transition-all"
|
||||
/>
|
||||
</div>
|
||||
{/* Invite Modal Overlay */}
|
||||
<AnimatePresence>
|
||||
{isInviteOpen && (
|
||||
<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={() => setIsInviteOpen(false)}
|
||||
className="fixed inset-0 bg-ink-900/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 }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 350 }}
|
||||
className="bg-ink-0 border border-ink-200 rounded-xl shadow-xl p-5 max-w-md w-full relative z-10 overflow-hidden text-ink-900"
|
||||
>
|
||||
<div className="absolute top-0 inset-x-0 h-px bg-gradient-to-r from-transparent via-ink-300 to-transparent" />
|
||||
|
||||
<div className="absolute top-0 right-0 p-4 opacity-5 pointer-events-none">
|
||||
<UserPlus className="w-24 h-24 text-ink-900" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !email}
|
||||
className="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-ink-900 text-ink-0 font-bold text-sm hover:bg-ink-800 hover:shadow-lg transition-all disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? 'Generating...' : 'Generate Invite Link'}
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</form>
|
||||
<h3 className="text-lg font-bold text-ink-900 mb-2 tracking-tight">Invite Partner</h3>
|
||||
<p className="text-xs text-ink-500 mb-6 font-medium">
|
||||
Generate a secure invitation link for a new partner.
|
||||
</p>
|
||||
|
||||
<AnimatePresence>
|
||||
{inviteResult && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
className="mt-6 overflow-hidden"
|
||||
>
|
||||
{inviteResult.error ? (
|
||||
<div className="p-4 bg-red-500/10 border border-red-500/20 rounded-xl flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-red-650 shrink-0 mt-0.5" />
|
||||
{!inviteResult?.token ? (
|
||||
<form onSubmit={handleInvite} className="space-y-4">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Email Address</label>
|
||||
<div className="relative">
|
||||
<Mail className="w-4 h-4 text-ink-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="partner@company.com"
|
||||
required
|
||||
className="w-full pl-9 pr-4 py-2 bg-ink-50 border border-ink-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400 transition-all font-semibold"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{inviteResult?.error && (
|
||||
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg flex items-start gap-3">
|
||||
<AlertCircle className="w-4 h-4 text-red-650 shrink-0 mt-0.5" />
|
||||
<p className="text-xs font-bold text-red-650">{inviteResult.error}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 bg-ink-100 border border-ink-300 rounded-xl">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<CheckCircle className="w-4 h-4 text-ink-900" />
|
||||
<span className="text-xs font-bold text-ink-900">Invite Created!</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-ink-500 mb-2 font-medium">Send this secure link to the partner:</p>
|
||||
<div className="p-2 bg-ink-0 border border-ink-300 rounded-lg text-xs break-all font-mono text-ink-900">
|
||||
{window.location.origin}/invite?token={inviteResult.token}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Partner List */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="bg-ink-0 rounded-2xl border border-ink-200 shadow-sm overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm whitespace-nowrap">
|
||||
<thead className="bg-ink-50 border-b border-ink-200 text-ink-500 font-bold uppercase tracking-wider text-xs">
|
||||
<tr>
|
||||
<th className="px-6 py-4">Partner</th>
|
||||
<th className="px-6 py-4">Status</th>
|
||||
<th className="px-6 py-4">MFA</th>
|
||||
<th className="px-6 py-4">Joined</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink-200">
|
||||
{loadingPartners ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-6 py-12 text-center">
|
||||
<div className="w-6 h-6 border-4 border-ink-900/30 border-t-ink-900 rounded-full animate-spin mx-auto" />
|
||||
</td>
|
||||
</tr>
|
||||
) : partners.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-6 py-12 text-center">
|
||||
<div className="w-12 h-12 bg-ink-50 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<Users className="w-6 h-6 text-ink-400" />
|
||||
</div>
|
||||
<p className="text-sm font-bold text-ink-900">No partners yet</p>
|
||||
<p className="text-xs text-ink-500 mt-1">Use the invite form to add your first partner.</p>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
partners.map(partner => {
|
||||
const sc = getStatusConfig(partner.onboardingStatus);
|
||||
return (
|
||||
<tr key={partner.id} className="hover:bg-ink-50 transition-colors">
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-ink-900 to-ink-800 flex items-center justify-center text-ink-0 font-bold text-xs shadow-md">
|
||||
{partner.email.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span className="font-bold text-ink-900">{partner.email}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`text-xs font-bold px-2.5 py-1 rounded-md border ${sc.color} ${sc.bg} ${sc.border}`}>
|
||||
{sc.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{partner.mfaEnabled ? (
|
||||
<span className="text-xs font-bold text-ink-900">Enabled</span>
|
||||
) : (
|
||||
<span className="text-xs font-bold text-ink-400">Disabled</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-xs text-ink-500 font-medium">
|
||||
{new Date(partner.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsInviteOpen(false)}
|
||||
className="flex-1 py-2.5 rounded-lg border border-ink-200 text-ink-700 font-bold text-sm hover:bg-ink-50 transition-all cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={inviteMutation.isPending || !email}
|
||||
className="flex-1 flex items-center justify-center gap-2 py-2.5 rounded-lg bg-ink-900 text-ink-0 font-bold text-sm hover:bg-ink-800 transition-all disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{inviteMutation.isPending ? 'Generating...' : 'Generate'}
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="p-3 bg-ink-100 border border-ink-300 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<CheckCircle className="w-4 h-4 text-ink-900" />
|
||||
<span className="text-xs font-bold text-ink-900">Invite Created!</span>
|
||||
</div>
|
||||
<p className="text-xs text-ink-500 mb-2 font-medium">Send this secure link to the partner:</p>
|
||||
<div className="p-2.5 bg-ink-0 border border-ink-300 rounded-lg text-xs break-all font-mono text-ink-900 select-all">
|
||||
{window.location.origin}/invite?token={inviteResult.token}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsInviteOpen(false);
|
||||
setEmail('');
|
||||
setInviteResult(null);
|
||||
}}
|
||||
className="w-full py-2.5 rounded-lg bg-ink-900 text-ink-0 font-bold text-sm hover:bg-ink-800 transition-all cursor-pointer"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { axiosInstance } from '../../services/axios';
|
||||
import { Shield, FileText, Upload, CheckCircle, AlertTriangle, ArrowRight, Eye, RefreshCw } from 'lucide-react';
|
||||
import PageHeader from '../../components/ui/PageHeader';
|
||||
|
||||
interface LegalDoc {
|
||||
id: string;
|
||||
@ -125,60 +126,60 @@ export const LegalTemplatesPage: React.FC = () => {
|
||||
const fileHost = (import.meta.env.VITE_API_URL || 'http://localhost:5000/api/v1').replace('/api/v1', '');
|
||||
|
||||
return (
|
||||
<div className="max-w-[1400px] mx-auto w-full p-4 md:p-8 animate-fade-in text-ink-900">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Shield className="w-5 h-5 text-ink-400" />
|
||||
<span className="text-[10px] uppercase font-bold text-ink-500 tracking-wider">Compliance Panel</span>
|
||||
<div className="w-full space-y-6 animate-fade-in text-ink-900">
|
||||
<PageHeader
|
||||
title="Legal Agreements"
|
||||
subtitle="Configure active documents required during partner onboarding."
|
||||
badge={
|
||||
<div className="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">
|
||||
<Shield className="w-3.5 h-3.5 text-ink-950" />
|
||||
<span>Compliance Panel</span>
|
||||
</div>
|
||||
<h1 className="text-4xl font-extrabold text-ink-900 tracking-tight">Legal Agreements</h1>
|
||||
<p className="text-sm font-medium text-ink-500 mt-1">Configure active documents required during partner onboarding.</p>
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
<div className="inline-flex p-1 rounded-lg bg-ink-100 border border-ink-200 shrink-0">
|
||||
<button
|
||||
onClick={() => setActiveTab('NDA')}
|
||||
className={`px-3 py-1.5 rounded-md text-xs font-bold transition-all cursor-pointer ${activeTab === 'NDA' ? 'bg-ink-0 text-ink-900 shadow-sm' : 'text-ink-500 hover:text-ink-900'}`}
|
||||
>
|
||||
Non-Disclosure (NDA)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('MSA')}
|
||||
className={`px-3 py-1.5 rounded-md text-xs font-bold transition-all cursor-pointer ${activeTab === 'MSA' ? 'bg-ink-0 text-ink-900 shadow-sm' : 'text-ink-500 hover:text-ink-900'}`}
|
||||
>
|
||||
Master Services (MSA)
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Tab switchers */}
|
||||
<div className="inline-flex p-1 rounded-xl bg-ink-100 border border-ink-200">
|
||||
<button
|
||||
onClick={() => setActiveTab('NDA')}
|
||||
className={`px-4 py-2 rounded-lg text-xs font-bold transition-all ${activeTab === 'NDA' ? 'bg-ink-0 text-ink-900 shadow-sm' : 'text-ink-500 hover:text-ink-900'}`}
|
||||
>
|
||||
Non-Disclosure (NDA)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('MSA')}
|
||||
className={`px-4 py-2 rounded-lg text-xs font-bold transition-all ${activeTab === 'MSA' ? 'bg-ink-0 text-ink-900 shadow-sm' : 'text-ink-500 hover:text-ink-900'}`}
|
||||
>
|
||||
Master Services (MSA)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left Side: Active Status Details */}
|
||||
<div className="lg:col-span-1 flex flex-col gap-6">
|
||||
<div className="p-6 bg-ink-0 border border-ink-200 shadow-sm rounded-2xl">
|
||||
<h2 className="text-lg font-bold text-ink-900 mb-4">Active Configuration</h2>
|
||||
<div className="p-4 bg-ink-0 border border-ink-200 shadow-sm rounded-xl">
|
||||
<h2 className="text-base font-bold text-ink-900 mb-4">Active Configuration</h2>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<RefreshCw className="w-8 h-8 text-ink-400 animate-spin mb-3" />
|
||||
<RefreshCw className="w-6 h-6 text-ink-400 animate-spin mb-3" />
|
||||
<span className="text-xs font-bold text-ink-400">Loading document state...</span>
|
||||
</div>
|
||||
) : currentDoc ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-3 rounded-xl bg-ink-50 border border-ink-100">
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-ink-50 border border-ink-100">
|
||||
<div>
|
||||
<p className="text-[10px] font-bold text-ink-400 uppercase tracking-wider">Version</p>
|
||||
<p className="text-lg font-extrabold text-ink-900">v{currentDoc.version}</p>
|
||||
<p className="text-xs font-semibold text-ink-400 uppercase tracking-wider">Version</p>
|
||||
<p className="text-base font-extrabold text-ink-900">v{currentDoc.version}</p>
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-bold bg-ink-900 text-ink-0 border border-ink-700 shadow-sm">
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-bold bg-ink-900 text-ink-0 border border-ink-700 shadow-sm">
|
||||
<CheckCircle className="w-3.5 h-3.5" />
|
||||
Active
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-[10px] font-bold text-ink-400 uppercase tracking-wider mb-2">Display Mode</p>
|
||||
<p className="text-xs font-semibold text-ink-400 uppercase tracking-wider mb-2">Display Mode</p>
|
||||
{currentDoc.pdfUrl ? (
|
||||
<div className="flex items-center gap-2 text-xs font-semibold text-ink-900">
|
||||
<FileText className="w-4 h-4" />
|
||||
@ -194,12 +195,12 @@ export const LegalTemplatesPage: React.FC = () => {
|
||||
|
||||
{currentDoc.pdfUrl && (
|
||||
<div className="pt-2">
|
||||
<p className="text-[10px] font-bold text-ink-400 uppercase tracking-wider mb-2">File Source</p>
|
||||
<p className="text-xs font-semibold text-ink-400 uppercase tracking-wider mb-2">File Source</p>
|
||||
<a
|
||||
href={`${fileHost}${currentDoc.pdfUrl}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-between p-3.5 rounded-xl border border-dashed border-ink-200 hover:bg-ink-50 hover:border-ink-400 transition-all text-xs font-bold text-ink-700"
|
||||
className="flex items-center justify-between p-2 rounded-lg border border-dashed border-ink-200 hover:bg-ink-50 hover:border-ink-400 transition-all text-xs font-bold text-ink-700"
|
||||
>
|
||||
<span className="truncate max-w-[150px]">{currentDoc.pdfUrl.split('/').pop()}</span>
|
||||
<ArrowRight className="w-3.5 h-3.5" />
|
||||
@ -208,8 +209,8 @@ export const LegalTemplatesPage: React.FC = () => {
|
||||
)}
|
||||
|
||||
<div className="pt-4 border-t border-ink-100">
|
||||
<p className="text-[10px] font-bold text-ink-400 uppercase tracking-wider mb-2">Text Content Sample</p>
|
||||
<p className="text-xs text-ink-500 font-mono leading-relaxed line-clamp-4 bg-ink-50 p-3 rounded-lg border border-ink-100">
|
||||
<p className="text-xs font-semibold text-ink-400 uppercase tracking-wider mb-2">Text Content Sample</p>
|
||||
<p className="text-xs text-ink-500 font-mono leading-relaxed line-clamp-4 bg-ink-50 p-2.5 rounded-lg border border-ink-100">
|
||||
{currentDoc.content}
|
||||
</p>
|
||||
</div>
|
||||
@ -226,39 +227,39 @@ export const LegalTemplatesPage: React.FC = () => {
|
||||
|
||||
{/* Right Side: Publish New Revision form */}
|
||||
<div className="lg:col-span-2">
|
||||
<form onSubmit={handleFormSubmit} className="p-6 md:p-8 bg-ink-0 border border-ink-200 shadow-sm rounded-2xl flex flex-col gap-6">
|
||||
<form onSubmit={handleFormSubmit} className="p-4 md:p-5 bg-ink-0 border border-ink-200 shadow-sm rounded-xl flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-ink-900">Publish New Revision</h2>
|
||||
<h2 className="text-base font-bold text-ink-900">Publish New Revision</h2>
|
||||
<p className="text-xs text-ink-500 mt-1">
|
||||
Saving a new template automatically deactivates the current active version.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`p-4 rounded-xl flex items-start gap-3 border ${message.type === 'success' ? 'bg-ink-100 border-ink-200 text-ink-900' : 'bg-red-500/10 border-red-500/20 text-red-650'}`}>
|
||||
{message.type === 'success' && <CheckCircle className="w-5 h-5 shrink-0" />}
|
||||
<div className={`p-3 rounded-lg flex items-start gap-3 border ${message.type === 'success' ? 'bg-ink-100 border-ink-200 text-ink-900' : 'bg-red-500/10 border-red-500/20 text-red-650'}`}>
|
||||
{message.type === 'success' && <CheckCircle className="w-4 h-4 shrink-0 mt-0.5" />}
|
||||
<p className="text-xs font-semibold">{message.text}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="md:col-span-1">
|
||||
<label className="block text-xs font-bold uppercase tracking-wider text-ink-400 mb-2">Version String</label>
|
||||
<label className="block text-xs font-semibold text-ink-500 mb-2 uppercase tracking-wider">Version String</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. 1.1"
|
||||
value={version}
|
||||
onChange={(e) => setVersion(e.target.value)}
|
||||
className="w-full px-4 py-2.5 bg-ink-50 border border-ink-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400"
|
||||
className="w-full px-3 py-2 bg-ink-50 border border-ink-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400 shadow-sm font-semibold"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-3">
|
||||
<label className="block text-xs font-bold uppercase tracking-wider text-ink-400 mb-2">PDF Template (Optional)</label>
|
||||
<label className="block text-xs font-semibold text-ink-500 mb-2 uppercase tracking-wider">PDF Template (Optional)</label>
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex items-center gap-3 p-3 rounded-lg border border-dashed border-ink-200 hover:border-ink-400 cursor-pointer bg-ink-50 hover:bg-ink-100/50 transition-colors"
|
||||
className="flex items-center gap-3 p-2 rounded-lg border border-dashed border-ink-200 hover:border-ink-400 cursor-pointer bg-ink-50 hover:bg-ink-100/50 transition-colors"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg bg-ink-0 border border-ink-200 flex items-center justify-center text-ink-500">
|
||||
<Upload className="w-4 h-4" />
|
||||
@ -281,13 +282,13 @@ export const LegalTemplatesPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-bold uppercase tracking-wider text-ink-400 mb-2">Text Content Fallback</label>
|
||||
<label className="block text-xs font-semibold text-ink-500 mb-2 uppercase tracking-wider">Text Content Fallback</label>
|
||||
<textarea
|
||||
rows={8}
|
||||
rows={6}
|
||||
placeholder="Insert standard text content here..."
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
className="w-full px-4 py-3 bg-ink-50 border border-ink-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400 font-mono text-xs leading-relaxed"
|
||||
className="w-full px-3 py-2 bg-ink-50 border border-ink-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400 font-mono text-xs leading-relaxed shadow-sm"
|
||||
required
|
||||
/>
|
||||
<p className="text-[10px] text-ink-500 font-medium mt-1.5">
|
||||
@ -295,11 +296,11 @@ export const LegalTemplatesPage: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-ink-100">
|
||||
<div className="flex justify-end gap-3 pt-3 border-t border-ink-100">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || uploadingPdf}
|
||||
className="px-5 py-3 rounded-xl bg-ink-900 text-ink-0 font-bold text-sm hover:bg-ink-800 transition-all disabled:opacity-50 flex items-center gap-2 shadow-sm"
|
||||
className="px-4 py-2 rounded-lg bg-ink-900 text-ink-0 font-bold text-sm hover:bg-ink-800 transition-all disabled:opacity-50 flex items-center gap-2 shadow-sm cursor-pointer"
|
||||
>
|
||||
{uploadingPdf ? 'Uploading PDF...' : submitting ? 'Publishing...' : 'Publish Template'}
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
|
||||
54
Channel-Frontend/src/services/assets-api.ts
Normal file
54
Channel-Frontend/src/services/assets-api.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { axiosInstance } from './axios';
|
||||
import type { Asset, Organization } from '../types/assets';
|
||||
|
||||
export const getAssets = async (): Promise<Asset[]> => {
|
||||
const response = await axiosInstance.get<Asset[]>('/assets');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getOrganizations = async (): Promise<Organization[]> => {
|
||||
const response = await axiosInstance.get<Organization[]>('/organizations');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const uploadAsset = async (formData: FormData): Promise<void> => {
|
||||
await axiosInstance.post('/assets/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
};
|
||||
|
||||
export const updateAsset = async (
|
||||
id: string,
|
||||
payload: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
categoryId?: string;
|
||||
subcategory?: string;
|
||||
tags?: string[];
|
||||
githubUrl?: string;
|
||||
isDownloadable?: boolean;
|
||||
shares?: Array<{ organizationId: string; userId: string | null }>;
|
||||
}
|
||||
): Promise<void> => {
|
||||
await axiosInstance.patch(`/assets/${id}`, payload);
|
||||
};
|
||||
|
||||
export const deleteAsset = async (id: string): Promise<void> => {
|
||||
await axiosInstance.delete(`/assets/${id}`);
|
||||
};
|
||||
|
||||
export const requestDownload = async (id: string): Promise<void> => {
|
||||
await axiosInstance.post(`/assets/${id}/request-download`);
|
||||
};
|
||||
|
||||
export const approveDownloadRequest = async (assetId: string, requestId: string): Promise<void> => {
|
||||
await axiosInstance.post(`/assets/${assetId}/approve-download/${requestId}`);
|
||||
};
|
||||
|
||||
export const rejectDownloadRequest = async (assetId: string, requestId: string): Promise<void> => {
|
||||
await axiosInstance.post(`/assets/${assetId}/reject-download/${requestId}`);
|
||||
};
|
||||
|
||||
export const downloadAssetFile = async (id: string): Promise<void> => {
|
||||
await axiosInstance.post(`/assets/${id}/download`);
|
||||
};
|
||||
@ -5,8 +5,22 @@ export const AUTH_API_ROUTES = {
|
||||
login: "/auth/login",
|
||||
refresh: "/auth/refresh",
|
||||
logout: "/auth/logout",
|
||||
partners: "/auth/partners",
|
||||
invite: "/auth/invite",
|
||||
};
|
||||
|
||||
export interface Partner {
|
||||
id: string;
|
||||
email: string;
|
||||
onboardingStatus: string;
|
||||
mfaEnabled: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface InviteResponse {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export const loginUser = async (params: LoginParams): Promise<AuthResponse> => {
|
||||
const response = await axiosInstance.post<AuthResponse>(
|
||||
AUTH_API_ROUTES.login,
|
||||
@ -21,3 +35,18 @@ export const refreshAuthToken = async (): Promise<AuthResponse> => {
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getPartners = async (): Promise<Partner[]> => {
|
||||
const response = await axiosInstance.get<Partner[]>(
|
||||
AUTH_API_ROUTES.partners
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const invitePartner = async (email: string): Promise<InviteResponse> => {
|
||||
const response = await axiosInstance.post<InviteResponse>(
|
||||
AUTH_API_ROUTES.invite,
|
||||
{ email }
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
76
Channel-Frontend/src/services/legal-api.ts
Normal file
76
Channel-Frontend/src/services/legal-api.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { axiosInstance } from "./axios";
|
||||
|
||||
export interface PendingPartner {
|
||||
id: string;
|
||||
email: string;
|
||||
createdAt: string;
|
||||
acceptances: Array<{
|
||||
id: string;
|
||||
signatureHash: string | null;
|
||||
documentUrl: string | null;
|
||||
document: {
|
||||
type: string;
|
||||
version: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface LegalDocument {
|
||||
id: string;
|
||||
type: 'NDA' | 'MSA';
|
||||
content: string;
|
||||
pdfUrl?: string | null;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface LegalAcceptance {
|
||||
id: string;
|
||||
document: {
|
||||
id: string;
|
||||
type: 'NDA' | 'MSA';
|
||||
version: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const LEGAL_API_ROUTES = {
|
||||
pending: "/legal/pending",
|
||||
approve: (partnerId: string) => `/legal/approve/${partnerId}`,
|
||||
activeDoc: (type: 'NDA' | 'MSA') => `/legal/documents/active/${type}`,
|
||||
myAcceptances: "/legal/my-acceptances",
|
||||
upload: "/legal/upload",
|
||||
sign: "/legal/sign",
|
||||
};
|
||||
|
||||
export const getPendingPartners = async (): Promise<PendingPartner[]> => {
|
||||
const response = await axiosInstance.get<PendingPartner[]>(LEGAL_API_ROUTES.pending);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const approvePartnerAccess = async (partnerId: string): Promise<void> => {
|
||||
await axiosInstance.post<void>(LEGAL_API_ROUTES.approve(partnerId));
|
||||
};
|
||||
|
||||
export const getActiveLegalDocument = async (type: 'NDA' | 'MSA'): Promise<LegalDocument> => {
|
||||
const response = await axiosInstance.get<LegalDocument>(LEGAL_API_ROUTES.activeDoc(type));
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getMyAcceptances = async (): Promise<LegalAcceptance[]> => {
|
||||
const response = await axiosInstance.get<LegalAcceptance[]>(LEGAL_API_ROUTES.myAcceptances);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const uploadSignedLegalDocument = async (formData: FormData): Promise<{ url: string }> => {
|
||||
const response = await axiosInstance.post<{ url: string }>(LEGAL_API_ROUTES.upload, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const signLegalDocument = async (payload: {
|
||||
documentType: 'NDA' | 'MSA';
|
||||
signatureBase64: string | null;
|
||||
documentUrl: string | null;
|
||||
}): Promise<void> => {
|
||||
await axiosInstance.post(LEGAL_API_ROUTES.sign, payload);
|
||||
};
|
||||
66
Channel-Frontend/src/types/assets.ts
Normal file
66
Channel-Frontend/src/types/assets.ts
Normal file
@ -0,0 +1,66 @@
|
||||
export interface SharedWithOrg {
|
||||
organizationId: string;
|
||||
userId: string | null;
|
||||
organization: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
user?: {
|
||||
id: string;
|
||||
email: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface DownloadRequest {
|
||||
id: string;
|
||||
assetId: string;
|
||||
userId: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
user?: {
|
||||
id: string;
|
||||
email: string;
|
||||
};
|
||||
asset?: {
|
||||
title: string;
|
||||
};
|
||||
}
|
||||
|
||||
export 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[];
|
||||
}
|
||||
|
||||
export interface OrgUser {
|
||||
id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface Organization {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
users?: OrgUser[];
|
||||
}
|
||||
|
||||
export interface ShareItem {
|
||||
organizationId: string;
|
||||
userId: string | null;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user