488 lines
19 KiB
React
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;
|