Initialize ZUJ OL Apps website with React + TypeScript + Vite
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { useI18n } from '../hooks/useI18n';
|
||||
import { siteData } from '../data/siteData';
|
||||
import DownloadTable from '../components/DownloadTable';
|
||||
import RoadmapGrid from '../components/RoadmapGrid';
|
||||
import ChangelogList from '../components/ChangelogList';
|
||||
import { NotebookPen, Terminal, MapPin, Smartphone, BookOpen, Monitor, Brain, KeyRound, ExternalLink, Download } from 'lucide-react';
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
const iconMap: Record<string, ComponentType<{ size?: number }>> = {
|
||||
NotebookPen, Terminal, MapPin, Smartphone, BookOpen, Monitor, Brain, KeyRound,
|
||||
};
|
||||
|
||||
export default function ProjectDetailPage() {
|
||||
const { id } = useParams();
|
||||
const { t, bi, biArray, lang } = useI18n();
|
||||
const p = siteData.projects.find(pr => pr.id === id);
|
||||
|
||||
if (!p) {
|
||||
return <div className="container"><div className="empty-state">Project not found</div></div>;
|
||||
}
|
||||
|
||||
const statusDef = siteData.statuses[p.status];
|
||||
const hasDownloads = p.downloads && p.downloads.length > 0;
|
||||
const IconComponent = iconMap[p.icon];
|
||||
|
||||
return (
|
||||
<div className="container fade-in">
|
||||
{/* Breadcrumb */}
|
||||
<div style={{ padding: '8px 0', fontSize: 13, color: 'var(--muted)' }}>
|
||||
<Link to="/" style={{ color: 'var(--muted)' }}>{bi(siteData.nav[0].label)}</Link>
|
||||
{' / '}
|
||||
<Link to="/projects" style={{ color: 'var(--muted)' }}>{bi(siteData.nav[1].label)}</Link>
|
||||
{' / '}
|
||||
<span style={{ color: 'var(--fg)' }}>{p.name}</span>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="detail-header">
|
||||
<div className="detail-header-top">
|
||||
<div className="detail-icon">
|
||||
{IconComponent ? <IconComponent size={28} /> : <span>{p.icon}</span>}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="detail-title">{p.displayName[lang] || p.name}</h1>
|
||||
<p className="detail-slogan">{bi(p.slogan)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="detail-badges">
|
||||
{p.techStack.map(ts => <span key={ts} className="badge badge-accent">{ts}</span>)}
|
||||
{p.platforms.map(pl => <span key={pl} className="badge">{pl}</span>)}
|
||||
<span className="badge badge-status" style={{ background: statusDef?.color || '#6B6B6B' }}>{bi(statusDef?.label)}</span>
|
||||
</div>
|
||||
<div className="detail-actions">
|
||||
<a href={p.repoUrl} target="_blank" className="btn btn-primary"><ExternalLink size={16} />GitHub</a>
|
||||
{hasDownloads && <Link to="/downloads" className="btn"><Download size={16} />{t('common.download')}</Link>}
|
||||
{p.docsUrl && <a href={p.docsUrl} className="btn"><BookOpen size={16} />{t('common.docs')}</a>}
|
||||
<a href={`${p.repoUrl}/issues`} target="_blank" className="btn btn-ghost"><ExternalLink size={16} />{t('contact.issues')}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="detail-body">
|
||||
<div className="detail-main">
|
||||
{/* Overview */}
|
||||
<div className="detail-section">
|
||||
<h2 className="detail-section-title">{t('detail.overview')}</h2>
|
||||
<p className="detail-prose">{bi(p.description)}</p>
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<div className="detail-section">
|
||||
<h2 className="detail-section-title">{t('detail.features')}</h2>
|
||||
<div className="feature-tags">
|
||||
{biArray(p.features).map((f, i) => <span key={i} className="feature-tag">{f}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Screenshots */}
|
||||
<div className="detail-section">
|
||||
<h2 className="detail-section-title">{t('detail.screenshots')}</h2>
|
||||
<ScreenshotCarousel count={3} placeholder={t('detail.screenshotPlaceholder')} />
|
||||
</div>
|
||||
|
||||
{/* Downloads */}
|
||||
{hasDownloads && (
|
||||
<div className="detail-section">
|
||||
<h2 className="detail-section-title">{t('detail.downloads')}</h2>
|
||||
<DownloadTable downloads={p.downloads} />
|
||||
<div className="trust-note">{t('downloads.trustNote')}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Roadmap */}
|
||||
{p.roadmap && (
|
||||
<div className="detail-section">
|
||||
<h2 className="detail-section-title">{t('detail.roadmap')}</h2>
|
||||
<RoadmapGrid roadmap={p.roadmap} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Changelog */}
|
||||
{p.changelog && p.changelog.length > 0 && (
|
||||
<div className="detail-section">
|
||||
<h2 className="detail-section-title">{t('detail.changelog')}</h2>
|
||||
<ChangelogList changelog={p.changelog} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Install guide */}
|
||||
<div className="detail-section">
|
||||
<h2 className="detail-section-title">{t('detail.installGuide')}</h2>
|
||||
<div className="install-list">
|
||||
<div className="install-item"><strong>Windows</strong>{t('detail.install.windows')}</div>
|
||||
<div className="install-item"><strong>macOS</strong>{t('detail.install.macos')}</div>
|
||||
<div className="install-item"><strong>Linux</strong>{t('detail.install.linux')}</div>
|
||||
{p.platforms.includes('android') && <div className="install-item"><strong>Android</strong>{t('detail.install.android')}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside className="detail-sidebar">
|
||||
<div className="detail-meta-panel">
|
||||
<div className="detail-meta-item">
|
||||
<span className="detail-meta-label">{t('detail.version')}</span>
|
||||
<span className="detail-meta-value mono">{p.latestVersion}</span>
|
||||
</div>
|
||||
<div className="detail-meta-item">
|
||||
<span className="detail-meta-label">{t('detail.status')}</span>
|
||||
<span className="detail-meta-value">{bi(statusDef?.label)}</span>
|
||||
</div>
|
||||
<div className="detail-meta-item">
|
||||
<span className="detail-meta-label">{t('detail.license')}</span>
|
||||
<span className="detail-meta-value">{p.license}</span>
|
||||
</div>
|
||||
<div className="detail-meta-item">
|
||||
<span className="detail-meta-label">{t('common.stars')}</span>
|
||||
<span className="detail-meta-value mono">{p.stars}</span>
|
||||
</div>
|
||||
<div className="detail-meta-item">
|
||||
<span className="detail-meta-label">{t('common.forks')}</span>
|
||||
<span className="detail-meta-value mono">{p.forks}</span>
|
||||
</div>
|
||||
<div className="detail-meta-item">
|
||||
<span className="detail-meta-label">{t('detail.lastUpdate')}</span>
|
||||
<span className="detail-meta-value">{p.lastUpdated}</span>
|
||||
</div>
|
||||
<div className="detail-meta-item">
|
||||
<span className="detail-meta-label">Language</span>
|
||||
<span className="detail-meta-value">{p.language}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="detail-link-grid">
|
||||
<a href={p.repoUrl} target="_blank" className="detail-link-btn"><ExternalLink size={15} />{t('detail.repo')}</a>
|
||||
{p.docsUrl && <a href={p.docsUrl} className="detail-link-btn"><BookOpen size={15} />{t('detail.docs')}</a>}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScreenshotCarousel({ count, placeholder }: { count: number; placeholder: string }) {
|
||||
const [active, setActive] = useState(0);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollTo = useCallback((i: number) => {
|
||||
setActive(i);
|
||||
scrollRef.current?.children[i]?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => { entries.forEach(e => { if (e.isIntersecting) { const idx = Array.from(el.children).indexOf(e.target); if (idx >= 0) setActive(idx); } }); },
|
||||
{ root: el, threshold: 0.6 }
|
||||
);
|
||||
Array.from(el.children).forEach(c => observer.observe(c));
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="screenshot-carousel">
|
||||
<div className="screenshot-scroll" ref={scrollRef}>
|
||||
{Array.from({ length: count }, (_, i) => (
|
||||
<div key={i} className="screenshot-slide">
|
||||
<div className="screenshot-placeholder">{placeholder} {i + 1}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="screenshot-dots">
|
||||
{Array.from({ length: count }, (_, i) => (
|
||||
<button key={i} className={`screenshot-dot ${i === active ? 'active' : ''}`} onClick={() => scrollTo(i)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user