Initialize ZUJ OL Apps website with React + TypeScript + Vite

This commit is contained in:
2026-05-22 09:21:24 +08:00
commit 5e79c96364
55 changed files with 7409 additions and 0 deletions
+127
View File
@@ -0,0 +1,127 @@
import { Bug, ExternalLink, MessageCircle, Shield } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { useI18n } from '../hooks/useI18n';
import { siteData } from '../data/siteData';
interface ContactCard {
icon: LucideIcon;
titleKey: string;
descKey: string;
href: string;
label: string;
}
const contactCards: ContactCard[] = [
{
icon: Bug,
titleKey: 'contact.issues',
descKey: 'contact.issues.desc',
href: `${siteData.brand.github}/issues`,
label: 'GitHub Issues',
},
{
icon: MessageCircle,
titleKey: 'contact.discussions',
descKey: 'contact.discussions.desc',
href: `${siteData.brand.github}/discussions`,
label: 'GitHub Discussions',
},
{
icon: Shield,
titleKey: 'contact.security',
descKey: 'contact.security.desc',
href: `${siteData.brand.github}/security`,
label: 'Security',
},
];
export default function AboutPage() {
const { t, bi, biArray } = useI18n();
const { about, brand } = siteData;
return (
<div className="container fade-in">
<div className="page-header">
<h1 className="page-title">{t('about.title')}</h1>
<p className="page-subtitle">{t('about.subtitle')}</p>
</div>
<div className="about-header">
<div className="about-avatar">SZ</div>
<div>
<div className="about-name">{brand.author}</div>
<div className="about-bio">{bi(about.bio)}</div>
</div>
</div>
<div className="section">
<div className="section-header">
<h2 className="section-title">{t('about.focus')}</h2>
</div>
<div className="focus-grid">
{biArray(about.focus).map((item) => (
<div key={item} className="focus-item">{item}</div>
))}
</div>
</div>
<div className="section">
<div className="section-header">
<h2 className="section-title">{t('about.techStack')}</h2>
</div>
<div className="techstack-grid">
{about.techStack.map((tech) => (
<span key={tech} className="tech-tag">{tech}</span>
))}
</div>
</div>
<div className="section">
<div className="section-header">
<h2 className="section-title">{t('about.opensource')}</h2>
</div>
<p className="about-bio">{t('cta.subtitle')}</p>
</div>
<div className="section">
<div className="section-header">
<h2 className="section-title">{t('contact.title')}</h2>
</div>
<div className="contact-grid">
{contactCards.map((card) => {
const Icon = card.icon;
return (
<a
key={card.titleKey}
href={card.href}
target="_blank"
rel="noopener noreferrer"
className="contact-card"
>
<h3>
<Icon size={18} />
{t(card.titleKey)}
</h3>
<p>{t(card.descKey)}</p>
<span className="contact-card-link">{card.label}</span>
</a>
);
})}
<a
href={about.github}
target="_blank"
rel="noopener noreferrer"
className="contact-card"
>
<h3>
<ExternalLink size={18} />
{t('about.github')}
</h3>
<p>{t('about.links')}</p>
<span className="contact-card-link">GitHub</span>
</a>
</div>
</div>
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
import { ExternalLink } from 'lucide-react';
import { useI18n } from '../hooks/useI18n';
import { siteData } from '../data/siteData';
import Hero from '../components/Hero';
import StatsBar from '../components/StatsBar';
import SectionHeader from '../components/SectionHeader';
import FeaturedCard from '../components/FeaturedCard';
export default function HomePage() {
const { t } = useI18n();
const featuredProjects = siteData.projects.filter((p) => p.featured);
return (
<div className="fade-in">
<Hero />
<div className="container">
<StatsBar />
<div className="section">
<SectionHeader title={t('featured.title')} subtitle={t('featured.subtitle')} />
<div className="card-grid">
{featuredProjects.map((p) => (
<FeaturedCard key={p.id} project={p} />
))}
</div>
</div>
<div className="section section-compact" style={{ textAlign: 'center' }}>
<h2 className="section-title">{t('cta.title')}</h2>
<p className="section-subtitle" style={{ margin: '8px auto 0', maxWidth: 600 }}>
{t('cta.subtitle')}
</p>
<div style={{ marginTop: 24 }}>
<a
href={siteData.brand.github}
target="_blank"
rel="noopener noreferrer"
className="btn btn-primary"
>
<ExternalLink size={16} /> {t('cta.button')}
</a>
</div>
</div>
</div>
</div>
);
}
+202
View File
@@ -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>
);
}
+98
View File
@@ -0,0 +1,98 @@
import { useSearchParams } from 'react-router-dom';
import { useI18n } from '../hooks/useI18n';
import { useProjectFilters } from '../hooks/useProjectFilters';
import { siteData } from '../data/siteData';
import ProjectCard from '../components/ProjectCard';
import SelectControl from '../components/SelectControl';
export default function ProjectsPage() {
const { t, bi } = useI18n();
const [searchParams] = useSearchParams();
const catParam = searchParams.get('cat');
const {
search, setSearch,
tech, setTech,
platform, setPlatform,
status, setStatus,
sort, setSort,
allTech, allPlatforms, allStatuses,
filteredProjects,
} = useProjectFilters();
const displayedProjects = catParam
? filteredProjects.filter(p => p.type.includes(catParam))
: filteredProjects;
const techOptions = [
{ value: '', label: t('projects.filter.all') },
...allTech.map((item) => ({ value: item, label: item })),
];
const platformOptions = [
{ value: '', label: t('projects.filter.all') },
...allPlatforms.map((item) => ({ value: item, label: item })),
];
const statusOptions = [
{ value: '', label: t('projects.filter.all') },
...allStatuses.map((item) => ({ value: item, label: bi(siteData.statuses[item]?.label) })),
];
const sortOptions = [
{ value: 'updated', label: t('projects.sort.updated') },
{ value: 'stars', label: t('projects.sort.stars') },
{ value: 'name', label: t('projects.sort.name') },
];
return (
<div className="container fade-in">
<div className="page-header">
<h1 className="page-title">{t('projects.title')}</h1>
<p className="page-subtitle">{t('projects.subtitle')}</p>
</div>
<div className="filter-bar">
<input
type="text"
className="search-input"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={t('projects.search')}
/>
<div className="filter-group">
<span className="filter-group-label">{t('projects.filter.tech')}</span>
<SelectControl
value={tech}
options={techOptions}
onChange={setTech}
searchable={techOptions.length > 8}
searchPlaceholder={t('projects.filter.tech')}
emptyLabel={t('projects.noResults')}
/>
</div>
<div className="filter-group">
<span className="filter-group-label">{t('projects.filter.platform')}</span>
<SelectControl value={platform} options={platformOptions} onChange={setPlatform} />
</div>
<div className="filter-group">
<span className="filter-group-label">{t('projects.filter.status')}</span>
<SelectControl value={status} options={statusOptions} onChange={setStatus} />
</div>
<div className="filter-group filter-group-sort">
<span className="filter-group-label">Sort</span>
<SelectControl value={sort} options={sortOptions} onChange={setSort} />
</div>
</div>
<div className="card-grid">
{displayedProjects.length === 0 ? (
<div className="empty-state">{t('projects.noResults')}</div>
) : (
displayedProjects.map(p => <ProjectCard key={p.id} project={p} />)
)}
</div>
</div>
);
}