Modernize frontend and centralize branding
This commit is contained in:
1 parent
bc4542d0fb
commit
2d61d80550
55 files changed
+1901
-14446
No files matched your search
@@ -0,0 +1,484 @@
|
||||
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>
|
||||
<div
|
||||
className="prose prose-invert max-w-none"
|
||||
style={{ color: colors.foreground }}
|
||||
dangerouslySetInnerHTML={{ __html: plugin.description }}
|
||||
/>
|
||||
</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;
|
||||
Reference in new issue
Block a user