Market-Frontend/src/components/PluginCard.tsx
2026-04-26 10:37:59 +02:00

175 lines
7.6 KiB
TypeScript

import { useState, useRef, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
const getAccentColor = (name: string) => {
const colors = [
'#4f7eff', '#ff6b6b', '#ffd166', '#06d6a0',
'#a855f7', '#f97316', '#06b6d4', '#ec4899',
];
let hash = 0;
for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
return colors[Math.abs(hash) % colors.length];
};
const getEffectivePrice = (plugin: any): { display: string; isSale: boolean; originalDisplay: string } => {
const base = plugin.price_cents ?? 0;
const saleActive = plugin.sale_active && plugin.sale_discount_percent > 0;
if (saleActive) {
const saleCents = Math.round(base * (1 - plugin.sale_discount_percent / 100));
return {
display: `€${(saleCents / 100).toFixed(2)}`,
isSale: true,
originalDisplay: `€${(base / 100).toFixed(2)}`,
};
}
return {
display: `€${(base / 100).toFixed(2)}`,
isSale: false,
originalDisplay: '',
};
};
const PluginCard = ({ plugin }: { plugin: any }) => {
const { i18n } = useTranslation();
const [currentIndex, setCurrentIndex] = useState(0);
const [isHovering, setIsHovering] = useState(false);
const timerRef = useRef<any>(null);
const cycleRef = useRef<any>(null);
const accent = useMemo(() => getAccentColor(plugin.name), [plugin.name]);
const getDescription = (translatedStr: string) => {
try {
const data = JSON.parse(translatedStr);
const currentLang = i18n.language.split('-')[0];
return data[currentLang] || data.en || Object.values(data)[0] || 'No description available.';
} catch {
return 'No description available.';
}
};
const priceInfo = useMemo(() => getEffectivePrice(plugin), [plugin]);
const isSale = plugin.type === 'paid' && priceInfo.isSale;
const desc = getDescription(plugin.translated_descriptions);
const initial = plugin.name.charAt(0).toUpperCase();
const slideshowImages = useMemo(() => {
const images = [];
if (plugin.preview_path) images.push(plugin.preview_path);
if (plugin.screenshots && plugin.screenshots.length > 0) {
images.push(...plugin.screenshots);
}
return images;
}, [plugin.preview_path, plugin.screenshots]);
const startHover = () => {
setIsHovering(true);
if (slideshowImages.length <= 1) return;
timerRef.current = setTimeout(() => {
cycleRef.current = setInterval(() => {
setCurrentIndex(prev => (prev + 1) % slideshowImages.length);
}, 2000);
}, 200);
};
const stopHover = () => {
setIsHovering(false);
setCurrentIndex(0);
if (timerRef.current) clearTimeout(timerRef.current);
if (cycleRef.current) clearInterval(cycleRef.current);
};
return (
<Link to={`/plugin/${plugin.id}`} className="plugin-card-link" onMouseEnter={startHover} onMouseLeave={stopHover}>
<div className="plugin-card-v2">
<div className="pcv2-preview">
{slideshowImages.length > 0 ? (
<div className="pcv2-slideshow">
{slideshowImages.map((src: string, i: number) => (
<img
key={src}
src={src}
className={`pcv2-slideshow-img ${i === currentIndex ? 'active' : ''}`}
alt={plugin.name}
/>
))}
{isHovering && slideshowImages.length > 1 && (
<div className="pcv2-slideshow-progress">
{slideshowImages.map((_: any, i: number) => (
<div key={i} className={`pcv2-progress-dot ${i === currentIndex ? 'active' : ''}`} />
))}
</div>
)}
</div>
) : (
<div className="pcv2-preview-fallback" style={{ background: `linear-gradient(135deg, ${accent}22 0%, #0d0f12 100%)` }}>
<span style={{ color: accent }}>{initial}</span>
</div>
)}
<div className="pcv2-preview-fade" />
<span className="pcv2-category-badge">{plugin.category}</span>
{plugin.type === 'free' ? (
<span className="pcv2-price-badge free">Free</span>
) : isSale ? (
<span className="pcv2-price-badge sale">
<span className="pcv2-price-original">{priceInfo.originalDisplay}</span>
{priceInfo.display}
<span className="pcv2-sale-pct">-{plugin.sale_discount_percent}%</span>
</span>
) : (
<span className="pcv2-price-badge paid">{priceInfo.display}</span>
)}
</div>
<div className="pcv2-info">
<p className="pcv2-name">
{plugin.name}
{plugin.is_early_access && <span className="pcv2-badge-ea">Early Access</span>}
</p>
<p className="pcv2-dev">by {plugin.dev_name}</p>
<p className="pcv2-desc">{desc}</p>
<div className="pcv2-footer">
<span className="pcv2-stat">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
</svg>
{(plugin.downloads ?? 0).toLocaleString()}
</span>
{plugin.rating != null && (
<span className="pcv2-stat">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" stroke="none">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" opacity="0.7"/>
</svg>
{Number(plugin.rating).toFixed(1)}
</span>
)}
{plugin.version && (
<span className="pcv2-stat" style={{ marginLeft: 'auto', fontFamily: 'var(--font-mono, monospace)', fontSize: '0.65rem' }}>
v{plugin.version}
</span>
)}
</div>
</div>
{isSale && (
<div className="pcv2-sale-strip">
🔥 Sale — {plugin.sale_discount_percent}% off
</div>
)}
<div
className="pcv2-accent-bar"
style={{ background: `linear-gradient(90deg, ${isSale ? '#ff6b6b' : accent}, transparent)` }}
/>
</div>
</Link>
);
};
export default PluginCard;