some fixes

This commit is contained in:
Alexander Medvedev 2026-07-30 17:30:55 +02:00
parent 5c3922f230
commit 92366a6781
9 changed files with 99 additions and 28 deletions

View File

@ -384,7 +384,7 @@ h3 {
}
.plugin-sidebar {
order: -1;
order: 1;
width: 100%;
margin: 0;
}

View File

@ -126,7 +126,7 @@ const App = () => (
{/* All routes share MainLayout (navbar + footer) */}
<Route path="/" element={<MainLayout />}>
<Route index element={<Home />} />
<Route path="plugin/:id" element={<PluginDetail />} />
<Route path="plugin/:id-:slug" element={<PluginDetail />} />
<Route path="profile/:username" element={<AuthorProfilePage />} />
<Route path="search" element={<SearchResultsPage />} />
<Route path="terms" element={<TermsOfServicePage />} />

View File

@ -1,6 +1,7 @@
import { useState, useRef, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { getPluginUrl } from '../utils/url';
const getAccentColor = (name: string) => {
const colors = [
@ -81,7 +82,7 @@ const PluginCard = ({ plugin }: { plugin: any }) => {
};
return (
<Link to={`/plugin/${plugin.id}`} className="plugin-card-link" onMouseEnter={startHover} onMouseLeave={stopHover}>
<Link to={getPluginUrl(plugin)} className="plugin-card-link" onMouseEnter={startHover} onMouseLeave={stopHover}>
<div className="plugin-card-v2">
<div className="pcv2-preview">
{slideshowImages.length > 0 ? (

View File

@ -3,6 +3,7 @@ import { useParams, Link } from 'react-router-dom';
import api from '../api';
import { useTranslation } from 'react-i18next';
import SEO from '../components/SEO';
import { getPluginUrl } from '../utils/url';
const AuthorProfilePage = () => {
const { username } = useParams();
@ -138,7 +139,7 @@ const AuthorProfilePage = () => {
const imageUrl = plugin.preview_path;
return (
<Link to={`/plugin/${plugin.id}`} key={plugin.id} className="plugin-card-link">
<Link to={getPluginUrl(plugin)} key={plugin.id} className="plugin-card-link">
<div className="plugin-card-v2">
<div className="pcv2-preview">
{imageUrl ? (

View File

@ -5,6 +5,8 @@ import api from '../api';
import SEO from '../components/SEO';
import PluginCard from '../components/PluginCard';
import { getPluginUrl } from '../utils/url';
// Global cache to persist data across component unmounts (navigation)
let homeCache: {
popular: any[];
@ -120,7 +122,7 @@ const Home = () => {
{activeFeatured && (
<section className="featured-showcase-container">
<div className="featured-showcase-main">
<Link to={`/plugin/${activeFeatured.id}`} className="featured-showcase-link">
<Link to={getPluginUrl(activeFeatured)} className="featured-showcase-link">
{/* Main visual display */}
<div className="featured-main-visual">
{activeImage ? (

View File

@ -135,7 +135,8 @@ const formatDate = (dateStr: string) => {
};
const PluginDetail = () => {
const { id } = useParams();
const { id: rawId, slug } = useParams();
const id = rawId ? rawId.split('-')[0] : rawId;
const { user } = useAuth();
const navigate = useNavigate();
const location = useLocation();
@ -233,6 +234,15 @@ const PluginDetail = () => {
} else if (res.data.screenshots?.length > 0) {
setMainScreenshot(res.data.screenshots[0].path);
}
// Canonical URL check & replace
if (res.data.name) {
const expectedSlug = res.data.name.toLowerCase().trim().replace(/[\s_\.\-]+/g, '-').replace(/[^\w\-]+/g, '').replace(/\-\-+/g, '-').replace(/^-+/, '').replace(/-+$/, '');
const targetPath = expectedSlug ? `/plugin/${res.data.id}-${expectedSlug}` : `/plugin/${res.data.id}`;
if (location.pathname !== targetPath || slug !== expectedSlug) {
window.history.replaceState({}, '', targetPath + location.search);
}
}
});
};
@ -485,33 +495,71 @@ const PluginDetail = () => {
return `Download ${sizeStr}`;
})();
const cleanDesc = currentDescription.replace(/[#*`_\[\]]/g, '').slice(0, 160).trim();
const productSchema = {
"@context": "https://schema.org/",
"@type": "SoftwareApplication",
"name": plugin.name,
"operatingSystem": "Minecraft",
"applicationCategory": "GameApplication",
"offers": {
"@type": "Offer",
"price": plugin.type === 'paid' ? (plugin.price_cents / 100).toFixed(2) : "0.00",
"priceCurrency": "EUR"
},
"aggregateRating": stats ? {
"@type": "AggregateRating",
"ratingValue": stats.avg,
"reviewCount": stats.total
} : undefined,
"author": {
"@type": "Person",
"name": plugin.dev_name
}
"@context": "https://schema.org",
"@graph": [
{
"@type": "SoftwareApplication",
"name": plugin.name,
"description": cleanDesc,
"operatingSystem": "Minecraft",
"applicationCategory": "GameApplication",
"softwareVersion": plugin.version || "1.0.0",
"fileSize": plugin.file_size ? `${plugin.file_size} B` : undefined,
"image": plugin.preview_path || "https://market.pumpkinmc.org/icon.png",
"offers": {
"@type": "Offer",
"price": plugin.type === 'paid' ? (plugin.price_cents / 100).toFixed(2) : "0.00",
"priceCurrency": "EUR",
"availability": "https://schema.org/InStock"
},
"aggregateRating": stats ? {
"@type": "AggregateRating",
"ratingValue": stats.avg,
"ratingCount": stats.total,
"bestRating": "5",
"worstRating": "1"
} : undefined,
"author": {
"@type": "Person",
"name": plugin.dev_name,
"url": `https://market.pumpkinmc.org/profile/${plugin.dev_name}`
}
},
{
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Home",
"item": "https://market.pumpkinmc.org"
},
{
"@type": "ListItem",
"position": 2,
"name": plugin.category || "Plugins",
"item": `https://market.pumpkinmc.org/search?category=${encodeURIComponent(plugin.category || '')}`
},
{
"@type": "ListItem",
"position": 3,
"name": plugin.name,
"item": `https://market.pumpkinmc.org/plugin/${plugin.id}`
}
]
}
]
};
return (
<div className="container">
<SEO
title={plugin.name}
description={currentDescription.substring(0, 160)}
description={cleanDesc || `Download ${plugin.name} by ${plugin.dev_name} on Pumpkin Market.`}
keywords={`${plugin.name}, minecraft plugin, ${plugin.category || ''}, ${plugin.keywords || ''}`}
ogType="product"
ogImage={plugin.preview_path || "/icon.png"}
/>

View File

@ -5,6 +5,7 @@ import api from '../api';
import { useAuth } from '../App';
import { getCodeList } from 'country-list';
import DeveloperOnboardingModal from '../components/DeveloperOnboardingModal';
import { getPluginUrl } from '../utils/url';
import {
User, Mail, Globe, Lock, Shield, CreditCard,
BookOpen, AlertTriangle, LogOut, CheckCircle,
@ -854,7 +855,7 @@ const ProfilePage = () => {
{library.map((entry, idx) => (
<Link
key={entry.plugin_id}
to={`/plugin/${entry.plugin_id}`}
to={getPluginUrl({ id: entry.plugin_id, name: entry.name })}
className="library-card"
style={{ animationDelay: `${idx * 0.05}s` }}
>

View File

@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom';
import { Settings, Plus, ExternalLink, Sparkles } from 'lucide-react';
import { useAuth } from '../../App';
import DeveloperOnboardingModal from '../../components/DeveloperOnboardingModal';
import { getPluginUrl } from '../../utils/url';
// --- Main Component ---
const DashboardPlugins = () => {
@ -121,7 +122,7 @@ const DashboardPlugins = () => {
{/* View on Marketplace */}
<button
className="btn btn-icon-only"
onClick={() => navigate(`/plugin/${p.id}`)}
onClick={() => navigate(getPluginUrl(p))}
title="View in Store"
>
<ExternalLink size={16} />

17
src/utils/url.ts Normal file
View File

@ -0,0 +1,17 @@
export const slugify = (text: string): string => {
if (!text) return '';
return text
.toString()
.toLowerCase()
.trim()
.replace(/[\s_\.\-]+/g, '-') // Replace spaces, underscores, dots, and hyphens with a single -
.replace(/[^\w\-]+/g, '') // Remove all non-word chars except hyphens
.replace(/\-\-+/g, '-') // Replace multiple hyphens with single hyphen
.replace(/^-+/, '') // Trim hyphens from start
.replace(/-+$/, ''); // Trim hyphens from end
};
export const getPluginUrl = (plugin: { id: number | string; name?: string }): string => {
const slug = plugin.name ? slugify(plugin.name) : '';
return slug ? `/plugin/${plugin.id}-${slug}` : `/plugin/${plugin.id}`;
};