420 lines
17 KiB
TypeScript
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>
|
|
);
|
|
};
|