Files
anchorhost-frontend/src/features/plugins/PluginDetails.jsx
T
wfalkowski b7b36b1355
Build and publish Docker image / build-and-publish (push) Successful in 14s
fix(admin): repair management views and harden plugin handling
2026-10-20 17:17:00 +00:00

488 lines
19 KiB
React

import React, { useState, useEffect, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { ArrowLeft, Download, Star, Calendar, Package, ExternalLink, Tag, Server } from 'lucide-react';
import { colors } from '../../theme/colors';
import { Dropdown } from '../../components/dashboardPrimitives';
import { API_BASE_URL, WS_BASE_URL as WS_URL } from '../../config/api';
import branding from '../../branding';
function PluginDetails() {
const { pluginId } = useParams();
const navigate = useNavigate();
const [plugin, setPlugin] = useState(null);
const [versions, setVersions] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [selectedVersion, setSelectedVersion] = useState(null);
const [servers, setServers] = useState([]);
const [selectedServer, setSelectedServer] = useState(null);
const [downloadingToServer, setDownloadingToServer] = useState(false);
const [downloadStatus, setDownloadStatus] = useState('');
const serverDropdownOptions = useMemo(
() => servers.map((server) => ({ value: server.id, label: server.name })),
[servers],
);
useEffect(() => {
const fetchPluginDetails = async () => {
setLoading(true);
setError('');
try {
// Fetch project 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();
setPlugin(projectData);
// Fetch versions
const versionsResponse = await fetch(
`${API_BASE_URL}/api/modrinth/project/${pluginId}/versions`
);
if (versionsResponse.ok) {
const versionsData = await versionsResponse.json();
setVersions(versionsData);
if (versionsData.length > 0) {
setSelectedVersion(versionsData[0]);
}
}
} catch (err) {
setError(err.message);
console.error('Error fetching plugin details:', err);
} finally {
setLoading(false);
}
};
const fetchServers = async () => {
try {
const token = localStorage.getItem('anchorhost_token');
if (!token) return;
const ws = new WebSocket(`${WS_URL}?token=${token}`);
ws.onopen = () => {
ws.send(JSON.stringify({ type: 'get_servers' }));
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'servers_list') {
// Filter only Minecraft servers
const minecraftServers = (data.servers || []).filter(
server => server.image === 'minecraft' && server.type === 'game'
);
setServers(minecraftServers);
ws.close();
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
ws.close();
};
} catch (err) {
console.error('Error fetching servers:', err);
}
};
if (pluginId) {
fetchPluginDetails();
fetchServers();
}
}, [pluginId]);
const formatDate = (dateString) => {
if (!dateString) return 'Unknown';
const date = new Date(dateString);
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
};
const handleDownload = () => {
if (selectedVersion && selectedVersion.files && selectedVersion.files.length > 0) {
const primaryFile = selectedVersion.files.find(f => f.primary) || selectedVersion.files[0];
window.open(primaryFile.url, '_blank');
}
};
const handleDownloadToServer = async () => {
if (!selectedServer || !selectedVersion) {
setError('Please select a server 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: selectedServer.id,
plugin_url: primaryFile.url,
plugin_name: plugin.title || pluginId,
file_name: primaryFile.filename || `${pluginId}.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('');
}
};
if (loading) {
return (
<div className="min-h-app flex items-center justify-center" style={{ backgroundColor: colors.background }}>
<div className="w-8 h-8 border-4 border-current border-t-transparent rounded-full animate-spin" style={{ color: colors.blue }} />
</div>
);
}
if (error || !plugin) {
return (
<div className="min-h-app flex items-center justify-center" style={{ backgroundColor: colors.background }}>
<div className="text-center">
<p className="text-lg mb-4" style={{ color: colors.brightRed }}>{error || 'Plugin not found'}</p>
<button
onClick={() => navigate('/plugins')}
className="px-6 py-3 rounded-xl border transition-all"
style={{
backgroundColor: colors.blue,
borderColor: colors.blue,
color: colors.brightWhite
}}
>
Back to Store
</button>
</div>
</div>
);
}
return (
<div className="min-h-app" style={{ backgroundColor: colors.background }}>
<div className="max-w-5xl mx-auto px-4 py-8">
{/* Back Button */}
<button
onClick={() => navigate('/plugins')}
className="mb-6 flex items-center gap-2 px-4 py-2 rounded-xl border transition-all hover:scale-105"
style={{
backgroundColor: colors.brightBlack,
borderColor: colors.brightBlack,
color: colors.brightWhite
}}
>
<ArrowLeft className="w-4 h-4" />
Back to Store
</button>
{/* Plugin Header */}
<div className="rounded-xl border p-6 mb-6" style={{ backgroundColor: colors.backgroundSecondary, borderColor: colors.brightBlack }}>
<div className="flex items-start gap-6">
{plugin.icon_url ? (
<img
src={plugin.icon_url}
alt={plugin.title}
className="w-24 h-24 rounded-xl object-cover flex-shrink-0"
/>
) : (
<div
className="w-24 h-24 rounded-xl flex items-center justify-center flex-shrink-0"
style={{ backgroundColor: colors.brightBlack }}
>
<Package className="w-12 h-12" style={{ color: colors.foreground }} />
</div>
)}
<div className="flex-1">
<h1 className="text-3xl font-bold mb-2" style={{ color: colors.brightWhite }}>
{plugin.title}
</h1>
<p className="text-lg mb-4" style={{ color: colors.foreground }}>
by {plugin.team || (plugin.team_data && plugin.team_data.name) || 'Unknown'}
</p>
<div className="flex flex-wrap gap-4 text-sm">
<div className="flex items-center gap-2">
<Download className="w-4 h-4" style={{ color: colors.blue }} />
<span style={{ color: colors.foreground }}>{plugin.downloads?.toLocaleString() || 0} downloads</span>
</div>
{plugin.followers !== undefined && (
<div className="flex items-center gap-2">
<Star className="w-4 h-4" style={{ color: colors.yellow }} />
<span style={{ color: colors.foreground }}>{plugin.followers?.toLocaleString() || 0} followers</span>
</div>
)}
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4" style={{ color: colors.foreground }} />
<span style={{ color: colors.foreground }}>Updated {formatDate(plugin.updated)}</span>
</div>
</div>
</div>
<a
href={`https://modrinth.com/project/${plugin.slug || pluginId}`}
target="_blank"
rel="noopener noreferrer"
className="px-4 py-2 rounded-xl border transition-all hover:scale-105 flex items-center gap-2"
style={{
backgroundColor: colors.blue,
borderColor: colors.blue,
color: colors.brightWhite
}}
>
<ExternalLink className="w-4 h-4" />
View on Modrinth
</a>
</div>
</div>
{/* Description */}
{plugin.description && (
<div className="rounded-xl border p-6 mb-6" style={{ backgroundColor: colors.backgroundSecondary, borderColor: colors.brightBlack }}>
<h2 className="text-xl font-semibold mb-4" style={{ color: colors.brightWhite }}>Description</h2>
<p
className="max-w-none whitespace-pre-wrap leading-relaxed"
style={{ color: colors.foreground }}
>
{typeof plugin.description === 'string'
? new DOMParser().parseFromString(plugin.description, 'text/html').body.textContent
: ''}
</p>
</div>
)}
{/* Categories/Tags */}
{plugin.categories && plugin.categories.length > 0 && (
<div className="rounded-xl border p-6 mb-6" style={{ backgroundColor: colors.backgroundSecondary, borderColor: colors.brightBlack }}>
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2" style={{ color: colors.brightWhite }}>
<Tag className="w-5 h-5" />
Categories
</h2>
<div className="flex flex-wrap gap-2">
{plugin.categories.map((category) => (
<span
key={category}
className="px-3 py-1 rounded-lg text-sm"
style={{ backgroundColor: colors.brightBlack, color: colors.foreground }}
>
{category}
</span>
))}
</div>
</div>
)}
{/* Loaders and Versions */}
{(plugin.loaders || plugin.game_versions) && (
<div className="rounded-xl border p-6 mb-6" style={{ backgroundColor: colors.backgroundSecondary, borderColor: colors.brightBlack }}>
<h2 className="text-xl font-semibold mb-4" style={{ color: colors.brightWhite }}>Compatibility</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{plugin.loaders && plugin.loaders.length > 0 && (
<div>
<h3 className="text-sm font-medium mb-2" style={{ color: colors.foreground }}>Supported Loaders</h3>
<div className="flex flex-wrap gap-2">
{plugin.loaders.map((loader) => (
<span
key={loader}
className="px-3 py-1 rounded-lg text-sm"
style={{ backgroundColor: colors.brightBlack, color: colors.foreground }}
>
{loader}
</span>
))}
</div>
</div>
)}
{plugin.game_versions && plugin.game_versions.length > 0 && (
<div>
<h3 className="text-sm font-medium mb-2" style={{ color: colors.foreground }}>Supported Versions</h3>
<div className="flex flex-wrap gap-2">
{plugin.game_versions.slice(0, 10).map((version) => (
<span
key={version}
className="px-3 py-1 rounded-lg text-sm"
style={{ backgroundColor: colors.brightBlack, color: colors.foreground }}
>
{version}
</span>
))}
{plugin.game_versions.length > 10 && (
<span className="px-3 py-1 rounded-lg text-sm" style={{ color: colors.foreground }}>
+{plugin.game_versions.length - 10} more
</span>
)}
</div>
</div>
)}
</div>
</div>
)}
{/* Versions */}
{versions.length > 0 && (
<div className="rounded-xl border p-6 mb-6" style={{ backgroundColor: colors.backgroundSecondary, borderColor: colors.brightBlack }}>
<h2 className="text-xl font-semibold mb-4" style={{ color: colors.brightWhite }}>Versions</h2>
<div className="space-y-3">
{versions.slice(0, 10).map((version) => (
<div
key={version.id}
className={`p-4 rounded-lg border cursor-pointer transition-all ${
selectedVersion?.id === version.id ? 'ring-2' : ''
}`}
style={{
backgroundColor: selectedVersion?.id === version.id ? colors.brightBlack : colors.black,
borderColor: selectedVersion?.id === version.id ? colors.blue : colors.brightBlack
}}
onClick={() => setSelectedVersion(version)}
>
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold mb-1" style={{ color: colors.brightWhite }}>
{version.name}
</h3>
<div className="flex flex-wrap gap-2 text-sm">
{version.game_versions && version.game_versions.map((v) => (
<span
key={v}
className="px-2 py-1 rounded text-xs"
style={{ backgroundColor: colors.brightBlack, color: colors.foreground }}
>
{v}
</span>
))}
{version.loaders && version.loaders.map((l) => (
<span
key={l}
className="px-2 py-1 rounded text-xs"
style={{ backgroundColor: colors.brightBlack, color: colors.foreground }}
>
{l}
</span>
))}
</div>
</div>
<div className="text-sm" style={{ color: colors.foreground }}>
{formatDate(version.date_published)}
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Download Buttons */}
{selectedVersion && selectedVersion.files && selectedVersion.files.length > 0 && (
<div className="sticky bottom-4 space-y-3">
{/* Server Selection Dropdown */}
{servers.length > 0 && (
<Dropdown
ariaLabel="Download target server"
placeholder="Select a server to download to"
value={selectedServer?.id || ''}
options={serverDropdownOptions}
onChange={(serverId) => setSelectedServer(servers.find((server) => server.id === serverId) || null)}
icon={Server}
/>
)}
{/* Download to Server Button */}
{selectedServer && (
<button
onClick={handleDownloadToServer}
disabled={downloadingToServer}
className="w-full py-4 rounded-xl border font-semibold text-lg transition-all hover:scale-105 flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
style={{
backgroundColor: colors.green,
borderColor: colors.green,
color: colors.brightWhite
}}
>
{downloadingToServer ? (
<>
<div className="w-5 h-5 border-2 border-current border-t-transparent rounded-full animate-spin" />
{downloadStatus || 'Downloading...'}
</>
) : (
<>
<Download className="w-5 h-5" />
Download to {selectedServer.name}
</>
)}
</button>
)}
{/* Regular Download Button */}
<button
onClick={handleDownload}
className="w-full py-4 rounded-xl border font-semibold text-lg transition-all hover:scale-105 flex items-center justify-center gap-2"
style={{
backgroundColor: colors.blue,
borderColor: colors.blue,
color: colors.brightWhite
}}
>
<Download className="w-5 h-5" />
Download {selectedVersion.name} to Computer
</button>
{/* Download Status */}
{downloadStatus && (
<div
className="px-4 py-3 rounded-xl text-sm"
style={{
backgroundColor: downloadStatus.includes('success') ? colors.green : colors.blue,
color: colors.brightWhite
}}
>
{downloadStatus}
</div>
)}
</div>
)}
</div>
</div>
);
}
export default PluginDetails;