Tech4biz-channel/Channel-Frontend/src/features/assets/components/AssetManagement.tsx

420 lines
17 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import type { Asset, Category } from '../../../types';
import { apiClient } from '../../../lib/api-client';
import { Plus, ToggleLeft, ToggleRight, Trash2, X, Sparkles, Folder, FileText, Tag, Link2 } from 'lucide-react';
export const AssetManagement: React.FC = () => {
const [assets, setAssets] = useState<Asset[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(true);
// Form Modal State
const [isOpen, setIsOpen] = useState(false);
const [title, setTitle] = useState('');
const [categoryId, setCategoryId] = useState('silicon');
const [subcategory, setSubcategory] = useState('');
const [description, setDescription] = useState('');
const [tagsInput, setTagsInput] = useState('');
const [thumbnailUrl, setThumbnailUrl] = useState('');
const [githubUrl, setGithubUrl] = useState('');
const [status, setStatus] = useState<'draft' | 'published'>('draft');
const [formError, setFormError] = useState('');
const [submitting, setSubmitting] = useState(false);
const fetchAssets = async () => {
setLoading(true);
try {
const response = await apiClient.get<Asset[]>('/assets');
setAssets(response.data);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
const fetchCategories = async () => {
try {
const response = await apiClient.get<Category[]>('/categories');
setCategories(response.data);
// Pre-set subcategory default
if (response.data.length > 0) {
setSubcategory(response.data[0].subcategories[0]);
}
} catch (err) {
console.error(err);
}
};
useEffect(() => {
fetchAssets();
fetchCategories();
}, []);
// Update subcategories when category changes in form
useEffect(() => {
const cat = categories.find(c => c.id === categoryId);
if (cat && cat.subcategories.length > 0) {
setSubcategory(cat.subcategories[0]);
}
}, [categoryId, categories]);
const handleToggleStatus = async (asset: Asset) => {
const updatedStatus = asset.status === 'published' ? 'draft' : 'published';
try {
const response = await apiClient.put<Asset>(`/assets/${asset.id}`, {
...asset,
status: updatedStatus,
});
setAssets(prev => prev.map(a => (a.id === asset.id ? response.data : a)));
} catch (err) {
console.error(err);
}
};
const handleDelete = async (id: string) => {
if (!window.confirm('Are you sure you want to delete this asset?')) return;
try {
await apiClient.delete(`/assets/${id}`);
setAssets(prev => prev.filter(a => a.id !== id));
} catch (err) {
console.error(err);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setFormError('');
if (!title.trim() || !description.trim()) {
setFormError('Title and description are required.');
return;
}
setSubmitting(true);
try {
// Parse tags
const tags = tagsInput
.split(',')
.map(t => t.trim())
.filter(t => t.length > 0);
const payload = {
title,
categoryId,
subcategory,
description,
tags,
thumbnailUrl: thumbnailUrl.trim() || undefined,
githubUrl: githubUrl.trim() || undefined,
status,
author: 'Portal Admin',
};
const response = await apiClient.post<Asset>('/assets/new', payload);
setAssets(prev => [response.data, ...prev]);
// Reset Form & Close
setTitle('');
setDescription('');
setTagsInput('');
setThumbnailUrl('');
setGithubUrl('');
setStatus('draft');
setIsOpen(false);
} catch (err) {
setFormError('Failed to create asset.');
} finally {
setSubmitting(false);
}
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold text-ink-800">Resource & Asset Catalog</h2>
<p className="text-sm text-ink-600">Publish silicon blocks, software utilities, or cloud infrastructure configurations.</p>
</div>
<button
onClick={() => setIsOpen(true)}
className="flex items-center gap-1.5 rounded-lg bg-primary-400 px-4 py-2.5 text-xs font-semibold text-ink-800 hover:bg-primary-300 transition-premium shadow-premium"
>
<Plus className="h-4 w-4" />
Create Asset
</button>
</div>
{/* Asset Table */}
<div className="rounded-xl border border-ink-100 bg-white shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-ink-100 bg-ink-50 text-xs font-bold uppercase tracking-wider text-ink-600">
<th className="px-5 py-3.5">Asset Title / Category</th>
<th className="px-5 py-3.5 hidden md:table-cell">Tags</th>
<th className="px-5 py-3.5 hidden sm:table-cell">Downloads</th>
<th className="px-5 py-3.5">Status</th>
<th className="px-5 py-3.5 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-ink-100 text-sm">
{loading ? (
<tr>
<td colSpan={5} className="px-5 py-8 text-center text-ink-600 animate-pulse">
Loading resources catalog...
</td>
</tr>
) : assets.length === 0 ? (
<tr>
<td colSpan={5} className="px-5 py-8 text-center text-ink-600">
No resources cataloged yet.
</td>
</tr>
) : (
assets.map(asset => (
<tr key={asset.id} className="hover:bg-ink-50/50 transition-colors">
<td className="px-5 py-4">
<div className="space-y-1">
<p className="font-bold text-ink-800">{asset.title}</p>
<div className="flex items-center gap-1.5 text-xs text-ink-600">
<span className="capitalize font-semibold text-primary-700">{asset.categoryId}</span>
<span></span>
<span>{asset.subcategory}</span>
</div>
</div>
</td>
<td className="px-5 py-4 hidden md:table-cell">
<div className="flex flex-wrap gap-1">
{asset.tags.map(t => (
<span key={t} className="rounded bg-ink-50 px-2 py-0.5 text-[10px] font-semibold text-ink-600 border border-ink-100">
{t}
</span>
))}
</div>
</td>
<td className="px-5 py-4 font-mono font-semibold text-ink-700 hidden sm:table-cell">
{asset.downloadsCount}
</td>
<td className="px-5 py-4">
<span
className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-wider border ${
asset.status === 'published'
? 'bg-success/10 text-success border-success/20'
: 'bg-ink-100 text-ink-600 border-ink-200'
}`}
>
{asset.status}
</span>
</td>
<td className="px-5 py-4 text-right">
<div className="flex items-center justify-end gap-1.5">
{/* Toggle Status Button */}
<button
onClick={() => handleToggleStatus(asset)}
className="rounded p-1.5 text-ink-600 hover:bg-ink-100 hover:text-ink-800 transition-colors"
title={asset.status === 'published' ? 'Set as Draft' : 'Publish Asset'}
>
{asset.status === 'published' ? (
<ToggleRight className="h-5 w-5 text-primary-600" />
) : (
<ToggleLeft className="h-5 w-5 text-ink-300" />
)}
</button>
{/* Delete Button */}
<button
onClick={() => handleDelete(asset.id)}
className="rounded p-1.5 text-danger hover:bg-danger/10 transition-colors"
title="Delete Asset"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Form Dialog Modal */}
{isOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink-800/20 backdrop-blur-sm p-4 animate-fade-in">
<div className="w-full max-w-xl rounded-2xl border border-ink-100 bg-white p-6 shadow-premium max-h-[90vh] overflow-y-auto relative animate-scale-up">
<button
onClick={() => setIsOpen(false)}
className="absolute right-4 top-4 rounded-full p-1.5 text-ink-600 hover:bg-ink-100 hover:text-ink-800 transition-colors"
>
<X className="h-5 w-5" />
</button>
<div className="mb-5">
<h3 className="text-lg font-bold text-ink-800 flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary-600" />
Catalog New Asset
</h3>
<p className="text-xs text-ink-600">Enter technical specifications and publish rules for the resource.</p>
</div>
{formError && (
<div className="mb-4 rounded-lg bg-danger/5 border border-danger/20 p-3 text-xs font-semibold text-danger">
{formError}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-semibold text-ink-700 mb-1 flex items-center gap-1.5">
<FileText className="h-3.5 w-3.5 text-ink-600" />
Asset Title / Name
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. RISC-V Cryptographic Processor Core"
className="w-full rounded-lg border border-ink-200 px-3.5 py-2 text-sm focus:border-primary-500 focus:outline-none"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-ink-700 mb-1 flex items-center gap-1.5">
<Folder className="h-3.5 w-3.5 text-ink-600" />
Category
</label>
<select
value={categoryId}
onChange={(e) => setCategoryId(e.target.value)}
className="w-full rounded-lg border border-ink-200 bg-white px-3.5 py-2 text-sm focus:border-primary-500 focus:outline-none"
>
{categories.map(c => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-semibold text-ink-700 mb-1">Subcategory</label>
<select
value={subcategory}
onChange={(e) => setSubcategory(e.target.value)}
className="w-full rounded-lg border border-ink-200 bg-white px-3.5 py-2 text-sm focus:border-primary-500 focus:outline-none"
>
{categories
.find(c => c.id === categoryId)
?.subcategories.map(sub => (
<option key={sub} value={sub}>{sub}</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-ink-700 mb-1">Technical Overview / Description</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Provide synthesizable attributes, SDK support, pipeline latency details, etc."
rows={4}
className="w-full rounded-lg border border-ink-200 px-3.5 py-2 text-sm focus:border-primary-500 focus:outline-none resize-y"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-ink-700 mb-1 flex items-center gap-1.5">
<Tag className="h-3.5 w-3.5 text-ink-600" />
Tags (comma-separated)
</label>
<input
type="text"
value={tagsInput}
onChange={(e) => setTagsInput(e.target.value)}
placeholder="FPGA, RISC-V, Open-Source"
className="w-full rounded-lg border border-ink-200 px-3.5 py-2 text-sm focus:border-primary-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-xs font-semibold text-ink-700 mb-1 flex items-center gap-1.5">
<Link2 className="h-3.5 w-3.5 text-ink-600" />
GitHub Repo URL
</label>
<input
type="url"
value={githubUrl}
onChange={(e) => setGithubUrl(e.target.value)}
placeholder="https://github.com/..."
className="w-full rounded-lg border border-ink-200 px-3.5 py-2 text-sm focus:border-primary-500 focus:outline-none"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-ink-700 mb-1">Thumbnail Photo URL</label>
<input
type="url"
value={thumbnailUrl}
onChange={(e) => setThumbnailUrl(e.target.value)}
placeholder="https://images.unsplash.com/..."
className="w-full rounded-lg border border-ink-200 px-3.5 py-2 text-sm focus:border-primary-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-xs font-semibold text-ink-700 mb-1">Publish Status</label>
<div className="flex gap-4 mt-2">
<label className="flex items-center gap-1.5 text-xs text-ink-800 font-semibold cursor-pointer">
<input
type="radio"
name="status"
checked={status === 'draft'}
onChange={() => setStatus('draft')}
className="text-primary-500 focus:ring-primary-500"
/>
Draft
</label>
<label className="flex items-center gap-1.5 text-xs text-ink-800 font-semibold cursor-pointer">
<input
type="radio"
name="status"
checked={status === 'published'}
onChange={() => setStatus('published')}
className="text-primary-500 focus:ring-primary-500"
/>
Published
</label>
</div>
</div>
</div>
<div className="border-t border-ink-100 pt-4 flex justify-end gap-3 mt-6">
<button
type="button"
onClick={() => setIsOpen(false)}
className="rounded-lg border border-ink-200 bg-white px-4 py-2 text-sm font-semibold text-ink-600 hover:bg-ink-50 transition-premium"
>
Cancel
</button>
<button
type="submit"
disabled={submitting}
className="rounded-lg bg-primary-400 px-5 py-2 text-sm font-semibold text-ink-800 hover:bg-primary-300 transition-premium shadow-premium disabled:opacity-50"
>
{submitting ? 'Creating...' : 'Create Asset'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};