Files
anchorhost-frontend/src/features/plugins/PluginStoreEmbedded.jsx
T

403 lines
17 KiB
React

import React, { useState, useEffect, useCallback } from 'react';
import {
Search, Download, RefreshCw, Package, AlertTriangle, ArrowLeft, Filter
} from "lucide-react";
import { colors, Dropdown, SectionCard, minecraftVersions } from '../../components/DashboardComponents';
import { API_BASE_URL } from '../../config/api';
import branding from '../../branding';
// Embedded Plugin Store Component for Dashboard
const PluginStoreEmbedded = ({ serverId }) => {
const [plugins, setPlugins] = useState([]);
const [loading, setLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [selectedGameVersion, setSelectedGameVersion] = useState('');
const [currentPage, setCurrentPage] = useState(0);
const [error, setError] = useState('');
const [selectedPlugin, setSelectedPlugin] = useState(null);
const [pluginDetails, setPluginDetails] = useState(null);
const [pluginVersions, setPluginVersions] = useState([]);
const [selectedVersion, setSelectedVersion] = useState(null);
const [downloadingToServer, setDownloadingToServer] = useState(false);
const [downloadStatus, setDownloadStatus] = useState('');
const [loadingDetails, setLoadingDetails] = useState(false);
const [totalResults, setTotalResults] = useState(0);
const handlePluginClick = async (pluginId) => {
setSelectedPlugin(pluginId);
setError('');
setLoadingDetails(true);
try {
// Fetch plugin details
const projectResponse = await fetch(
`${API_BASE_URL}/api/modrinth/project/${pluginId}`,
{
headers: {
'User-Agent': `${branding.name}/1.0`
}
}
);
if (!projectResponse.ok) {
throw new Error('Failed to fetch plugin details');
}
const projectData = await projectResponse.json();
setPluginDetails(projectData);
// Fetch versions
const versionsResponse = await fetch(
`${API_BASE_URL}/api/modrinth/project/${pluginId}/versions`
);
if (versionsResponse.ok) {
const versionsData = await versionsResponse.json();
setPluginVersions(versionsData);
if (versionsData.length > 0) {
setSelectedVersion(versionsData[0]);
}
}
} catch (err) {
setError(err.message);
console.error('Error fetching plugin details:', err);
} finally {
setLoadingDetails(false);
}
};
const handleBackToList = () => {
setSelectedPlugin(null);
setPluginDetails(null);
setPluginVersions([]);
setSelectedVersion(null);
setError('');
};
const handleDownloadToServer = async () => {
if (!serverId || !selectedVersion) {
setError('Please select a version first');
return;
}
if (!selectedVersion.files || selectedVersion.files.length === 0) {
setError('No file available for this version');
return;
}
const primaryFile = selectedVersion.files.find(f => f.primary) || selectedVersion.files[0];
setDownloadingToServer(true);
setDownloadStatus('Downloading plugin...');
try {
const token = localStorage.getItem('anchorhost_token');
if (!token) {
throw new Error('Not authenticated');
}
const response = await fetch(`${API_BASE_URL}/api/plugins/download-to-server`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
server_id: serverId,
plugin_url: primaryFile.url,
plugin_name: pluginDetails?.title || selectedPlugin,
file_name: primaryFile.filename || `${selectedPlugin}.jar`
})
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || 'Failed to download plugin to server');
}
setDownloadStatus('Plugin downloaded successfully!');
setTimeout(() => {
setDownloadStatus('');
setDownloadingToServer(false);
}, 3000);
} catch (err) {
setError(err.message);
setDownloadingToServer(false);
setDownloadStatus('');
}
};
const gameVersions = [
{ value: '', label: 'All Versions' },
...minecraftVersions.flatMap(series =>
series.versions.map(v => ({ value: v, label: v }))
)
];
const searchPlugins = useCallback(async (query = '', page = 0) => {
setLoading(true);
setError('');
try {
const params = new URLSearchParams({
query: query || '',
limit: '20',
offset: (page * 20).toString(),
project_type: 'plugin'
});
if (selectedGameVersion) {
params.append('game_versions', selectedGameVersion);
}
const response = await fetch(`${API_BASE_URL}/api/modrinth/search?${params.toString()}`);
if (!response.ok) {
throw new Error('Failed to search plugins');
}
const data = await response.json();
setPlugins(data.hits || []);
setTotalResults(data.total_hits || 0);
} catch (err) {
setError(err.message);
console.error('Error searching plugins:', err);
} finally {
setLoading(false);
}
}, [selectedGameVersion]);
useEffect(() => {
searchPlugins(searchQuery, currentPage);
}, [searchPlugins, searchQuery, currentPage]);
const handleSearch = (e) => {
e.preventDefault();
setCurrentPage(0);
searchPlugins(searchQuery, 0);
};
if (loadingDetails) {
return (
<div className="h-full flex justify-center items-center rounded-2xl animate-pulse" style={{ backgroundColor: 'rgba(0,0,0,0.1)' }}>
<RefreshCw className="w-8 h-8 animate-spin text-blue-500" />
</div>
);
}
if (selectedPlugin && pluginDetails) {
return (
<div className="h-full flex flex-col space-y-8 animate-in fade-in slide-in-from-right-4 duration-300">
<div className="flex items-center justify-between">
<button
onClick={handleBackToList}
className="flex items-center space-x-2 text-xs font-bold uppercase tracking-wider px-5 py-2.5 rounded-xl transition-all duration-200 hover:scale-105 border hover:bg-white/10"
style={{ color: colors.foreground, backgroundColor: colors.brightBlack, borderColor: colors.brightBlack }}
>
<ArrowLeft className="w-4 h-4" />
<span>Back to Store</span>
</button>
</div>
<SectionCard title={pluginDetails.title} icon={Package} color={colors.blue}>
<div className="flex flex-col md:flex-row items-center md:items-start gap-8">
<img
src={pluginDetails.icon_url || 'https://via.placeholder.com/150'}
alt={pluginDetails.title}
className="w-32 h-32 rounded-3xl shadow-2xl border-4 border-white/5"
/>
<div className="flex-1 text-center md:text-left">
<p className="text-base mb-6 leading-relaxed opacity-70" style={{ color: colors.foreground }}>{pluginDetails.description}</p>
<div className="flex flex-wrap justify-center md:justify-start items-center gap-4 text-xs">
<div className="px-4 py-2.5 rounded-xl border flex flex-col items-center justify-center min-w-[100px]" style={{ backgroundColor: colors.brightBlack, borderColor: colors.brightBlack }}>
<span className="text-[10px] uppercase opacity-50 mb-1">Downloads</span>
<span className="font-bold text-sm" style={{ color: colors.blue }}>{pluginDetails.downloads.toLocaleString()}</span>
</div>
<div className="px-4 py-2.5 rounded-xl border flex flex-col items-center justify-center min-w-[100px]" style={{ backgroundColor: colors.brightBlack, borderColor: colors.brightBlack }}>
<span className="text-[10px] uppercase opacity-50 mb-1">Last Update</span>
<span className="font-bold text-sm" style={{ color: colors.green }}>{new Date(pluginDetails.updated).toLocaleDateString()}</span>
</div>
</div>
</div>
</div>
</SectionCard>
<SectionCard title="Available Versions" icon={Download} color={colors.green}>
<div className="grid grid-cols-1 gap-4">
{pluginVersions.map((version) => (
<div
key={version.id}
onClick={() => setSelectedVersion(version)}
className={`p-5 rounded-2xl border transition-all duration-300 group cursor-pointer ${
selectedVersion?.id === version.id
? 'bg-blue-500/10'
: 'bg-black/20 hover:bg-white/5'
}`}
style={{ borderColor: selectedVersion?.id === version.id ? colors.blue : colors.brightBlack }}
>
<div className="flex justify-between items-center">
<div className="flex-1">
<div className="flex items-center gap-3 mb-1">
<span className="font-bold text-white text-base">{version.name}</span>
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-white/10 text-white/60">
{version.version_number}
</span>
</div>
<div className="flex flex-wrap gap-2">
{version.game_versions.slice(0, 5).map(gv => (
<span key={gv} className="text-[10px] px-2 py-0.5 rounded bg-black/40 text-blue-400 border border-blue-500/10">
{gv}
</span>
))}
{version.game_versions.length > 5 && <span className="text-[10px] opacity-40">+{version.game_versions.length - 5} more</span>}
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
setSelectedVersion(version);
handleDownloadToServer();
}}
disabled={downloadingToServer}
className={`ml-4 px-6 py-3 rounded-xl font-bold transition-all text-xs uppercase tracking-wider border flex items-center gap-2 ${
selectedVersion?.id === version.id
? 'bg-blue-500/20 text-blue-400 border-blue-500/30 hover:bg-blue-500/30'
: 'bg-white/5 text-white/60 border-transparent hover:bg-white/10 hover:text-white'
} disabled:opacity-50`}
>
{downloadingToServer && selectedVersion?.id === version.id ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
<span>Installing...</span>
</>
) : (
<>
<Download className="w-4 h-4" />
<span>Install</span>
</>
)}
</button>
</div>
</div>
))}
</div>
</SectionCard>
{downloadStatus && (
<div className="p-4 rounded-2xl bg-green-500/10 border border-green-500/20 text-green-400 text-sm font-bold flex items-center justify-center gap-3 animate-in fade-in slide-in-from-bottom-2">
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
{downloadStatus}
</div>
)}
</div>
);
}
return (
<div className="h-full flex flex-col space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-300">
<SectionCard title="Search Plugins" icon={Search} color={colors.blue}>
<div className="flex flex-col lg:flex-row gap-4">
<div className="flex-1 relative group">
<Search className="w-4 h-4 absolute left-4 top-1/2 transform -translate-y-1/2 text-white/50 group-focus-within:text-blue-400 transition-colors" />
<input
type="text"
placeholder="Search thousands of plugins..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch(e)}
className="w-full pl-11 pr-4 py-3 rounded-xl bg-black/40 border text-white placeholder:text-white/50 focus:border-primary/50 focus:bg-black/60 focus:outline-none transition-all font-medium text-sm"
style={{ borderColor: colors.brightBlack }}
/>
</div>
<div className="flex gap-3">
<div className="w-48">
<Dropdown
value={selectedGameVersion}
options={gameVersions}
onChange={setSelectedGameVersion}
placeholder="Version"
icon={Filter}
/>
</div>
</div>
</div>
</SectionCard>
<div className="flex-1 overflow-y-auto pr-2 custom-scrollbar">
{loading ? (
<div className="flex flex-col justify-center items-center h-64 space-y-4 opacity-50">
<RefreshCw className="w-10 h-10 animate-spin text-blue-500" />
<span className="text-sm font-bold tracking-widest text-blue-400 animate-pulse uppercase">Searching Modrinth...</span>
</div>
) : error ? (
<div className="text-center p-12 bg-red-500/5 border border-red-500/10 rounded-3xl">
<AlertTriangle className="w-16 h-16 mx-auto mb-4 text-red-500 opacity-50" />
<h4 className="text-xl font-bold text-white mb-2">Search Error</h4>
<p className="text-red-400/70">{error}</p>
</div>
) : plugins.length === 0 ? (
<div className="text-center p-12 bg-white/5 border border-dashed border-white/10 rounded-3xl">
<Package className="w-16 h-16 mx-auto mb-4 opacity-20" />
<h4 className="text-xl font-bold text-white/40 mb-2">No Plugins Found</h4>
<p className="text-white/20 text-sm">Try broadening your search or changing the version filter.</p>
</div>
) : (
<SectionCard title={`Results (${totalResults.toLocaleString()})`} icon={Package} color={colors.magenta}>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{plugins.map((plugin) => (
<div
key={plugin.slug}
onClick={() => handlePluginClick(plugin.slug)}
className="p-5 rounded-xl border transition-all cursor-pointer group flex flex-col h-full"
style={{ backgroundColor: colors.brightBlack, borderColor: colors.brightBlack }}
>
<div className="flex items-start gap-4 mb-4">
<div className="relative">
<img
src={plugin.icon_url || 'https://via.placeholder.com/64'}
alt={plugin.title}
className="w-16 h-16 rounded-xl shadow-lg group-hover:scale-110 transition-transform duration-500 relative z-10 bg-gray-900 border border-white/5"
/>
<div className="absolute inset-0 bg-blue-500/20 blur-xl rounded-full opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
</div>
<div className="flex-1 min-w-0">
<h4 className="font-bold text-white text-base leading-tight group-hover:text-blue-400 transition-colors truncate">
{plugin.title}
</h4>
<div className="flex items-center gap-2 mt-1">
<span className="text-[10px] font-bold text-white/40 uppercase tracking-tighter">
{plugin.author}
</span>
</div>
</div>
</div>
<p className="text-xs text-white/70 mb-6 line-clamp-2 leading-relaxed flex-1">
{plugin.description}
</p>
<div className="flex items-center justify-between pt-4 border-t border-white/5 mt-auto">
<div className="flex items-center gap-3 text-[10px] font-bold text-white/30 uppercase tracking-wider">
<span className="flex items-center gap-1.5 px-2 py-1 rounded bg-black/40">
<Download className="w-3 h-3 text-blue-500" />
{plugin.downloads.toLocaleString()}
</span>
</div>
<div className="p-1.5 rounded-lg bg-white/5 group-hover:bg-blue-500/20 group-hover:text-blue-400 transition-all">
<ArrowLeft className="w-3.5 h-3.5 rotate-180" />
</div>
</div>
</div>
))}
</div>
</SectionCard>
)}
</div>
</div>
);
};
export default PluginStoreEmbedded;