Skip to content

Commit 2f2321a

Browse files
committed
feat(comparison): add desktop app comparison
1 parent b631b04 commit 2f2321a

19 files changed

Lines changed: 405 additions & 6 deletions

File tree

scripts/validate/lib/routes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export function getStaticRoutes(): string[] {
3636
'/open-source-rank',
3737
'/search',
3838
'/clis/comparison',
39+
'/desktops/comparison',
3940
'/extensions/comparison',
4041
'/ides/comparison',
4142
'/models/comparison',
Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
'use client'
2+
3+
import { Download, FileText, Github, Home, Linkedin, Twitter, Youtube } from 'lucide-react'
4+
import { useTranslations } from 'next-intl'
5+
import { AppleIcon, LinuxIcon, WindowsIcon } from '@/components/controls/PlatformIcons'
6+
import Footer from '@/components/Footer'
7+
import Header from '@/components/Header'
8+
import { Breadcrumb } from '@/components/navigation/Breadcrumb'
9+
import ComparisonTable, { type ComparisonColumn } from '@/components/product/ComparisonTable'
10+
import { PricingSummaryValue } from '@/components/product/ProductPricing'
11+
import { withVendorCommunityUrlsForCatalog } from '@/lib/community-urls'
12+
import { desktopsData, vendorsData } from '@/lib/generated'
13+
import { getGithubStars } from '@/lib/generated/github-stars'
14+
import { renderLicense } from '@/lib/license'
15+
import type { PricingTier } from '@/lib/pricing'
16+
import type { ManifestDesktop, ManifestVendor } from '@/types/manifests'
17+
18+
const desktops = withVendorCommunityUrlsForCatalog(
19+
desktopsData as unknown as ManifestDesktop[],
20+
vendorsData as unknown as ManifestVendor[]
21+
)
22+
23+
type Props = {
24+
locale: string
25+
}
26+
27+
export default function DesktopComparisonPageClient({ locale: _locale }: Props) {
28+
const tPage = useTranslations('pages.comparison')
29+
const tShared = useTranslations('shared')
30+
const columns: ComparisonColumn[] = [
31+
{
32+
key: 'vendor',
33+
label: tShared('categories.singular.vendor'),
34+
},
35+
{
36+
key: 'license',
37+
label: tShared('terms.license'),
38+
render: (value: unknown, item: Record<string, unknown>) =>
39+
renderLicense(value, item, tShared),
40+
},
41+
{
42+
key: 'latestVersion',
43+
label: tShared('terms.version'),
44+
},
45+
{
46+
key: 'platforms',
47+
label: tShared('terms.platforms'),
48+
render: (value: unknown) => {
49+
const platforms = value as Array<{ os: string }> | string[]
50+
if (!platforms || platforms.length === 0) return '-'
51+
52+
// Handle both old format (string[]) and new format (Array<{ os: string }>)
53+
const platformNames = Array.isArray(platforms)
54+
? platforms.map(p => (typeof p === 'string' ? p : p.os))
55+
: []
56+
57+
return (
58+
<div className="flex gap-1.5 items-center">
59+
{platformNames.includes('macOS') && (
60+
<span title="macOS">
61+
<AppleIcon />
62+
</span>
63+
)}
64+
{platformNames.includes('Windows') && (
65+
<span title="Windows">
66+
<WindowsIcon />
67+
</span>
68+
)}
69+
{platformNames.includes('Linux') && (
70+
<span title="Linux">
71+
<LinuxIcon />
72+
</span>
73+
)}
74+
</div>
75+
)
76+
},
77+
},
78+
{
79+
key: 'githubStars',
80+
label: tShared('terms.stars'),
81+
render: (_: unknown, item: Record<string, unknown>) => {
82+
const githubUrl = item.githubUrl as string | null | undefined
83+
const stars = getGithubStars(githubUrl)
84+
85+
if (stars === null || stars === undefined)
86+
return <span className="text-right block">-</span>
87+
88+
const starsText = `${stars.toFixed(1)}k`
89+
90+
if (githubUrl) {
91+
return (
92+
<a
93+
href={githubUrl}
94+
target="_blank"
95+
rel="noopener"
96+
className="text-right block hover:text-[var(--color-text-secondary)] transition-colors hover:underline"
97+
>
98+
{starsText}
99+
</a>
100+
)
101+
}
102+
103+
return <span className="text-right block">{starsText}</span>
104+
},
105+
},
106+
{
107+
key: 'links',
108+
label: tPage('columns.links'),
109+
render: (_: unknown, item: Record<string, unknown>) => {
110+
const websiteUrl = item.websiteUrl as string | undefined
111+
const docsUrl = item.docsUrl as string | undefined
112+
const resourceUrls = item.resourceUrls as
113+
| {
114+
download?: string
115+
}
116+
| undefined
117+
const communityUrls = item.communityUrls as
118+
| {
119+
github?: string
120+
twitter?: string
121+
linkedin?: string
122+
youtube?: string
123+
reddit?: string
124+
}
125+
| undefined
126+
127+
return (
128+
<div className="flex gap-2 items-center">
129+
{websiteUrl ? (
130+
<a
131+
href={websiteUrl}
132+
target="_blank"
133+
rel="noopener"
134+
className="text-[var(--color-text)] hover:text-[var(--color-text-secondary)] transition-colors"
135+
title={tShared('terms.visitWebsite')}
136+
>
137+
<Home className="w-3.5 h-3.5" />
138+
</a>
139+
) : (
140+
<span className="text-[var(--color-text-muted)] opacity-30">
141+
<Home className="w-3.5 h-3.5" />
142+
</span>
143+
)}
144+
{resourceUrls?.download ? (
145+
<a
146+
href={resourceUrls.download}
147+
target="_blank"
148+
rel="noopener"
149+
className="text-[var(--color-text)] hover:text-[var(--color-text-secondary)] transition-colors"
150+
title={tShared('actions.download')}
151+
>
152+
<Download className="w-3.5 h-3.5" />
153+
</a>
154+
) : (
155+
<span className="text-[var(--color-text-muted)] opacity-30">
156+
<Download className="w-3.5 h-3.5" />
157+
</span>
158+
)}
159+
{docsUrl ? (
160+
<a
161+
href={docsUrl}
162+
target="_blank"
163+
rel="noopener"
164+
className="text-[var(--color-text)] hover:text-[var(--color-text-secondary)] transition-colors"
165+
title={tShared('terms.documentation')}
166+
>
167+
<FileText className="w-3.5 h-3.5" />
168+
</a>
169+
) : (
170+
<span className="text-[var(--color-text-muted)] opacity-30">
171+
<FileText className="w-3.5 h-3.5" />
172+
</span>
173+
)}
174+
{communityUrls?.github ? (
175+
<a
176+
href={communityUrls.github}
177+
target="_blank"
178+
rel="noopener"
179+
className="text-[var(--color-text)] hover:text-[var(--color-text-secondary)] transition-colors"
180+
title={tShared('platforms.github')}
181+
>
182+
<Github className="w-3.5 h-3.5" />
183+
</a>
184+
) : (
185+
<span className="text-[var(--color-text-muted)] opacity-30">
186+
<Github className="w-3.5 h-3.5" />
187+
</span>
188+
)}
189+
{communityUrls?.twitter ? (
190+
<a
191+
href={communityUrls.twitter}
192+
target="_blank"
193+
rel="noopener"
194+
className="text-[var(--color-text)] hover:text-[var(--color-text-secondary)] transition-colors"
195+
title={tShared('platforms.twitter')}
196+
>
197+
<Twitter className="w-3.5 h-3.5" />
198+
</a>
199+
) : (
200+
<span className="text-[var(--color-text-muted)] opacity-30">
201+
<Twitter className="w-3.5 h-3.5" />
202+
</span>
203+
)}
204+
{communityUrls?.linkedin ? (
205+
<a
206+
href={communityUrls.linkedin}
207+
target="_blank"
208+
rel="noopener"
209+
className="text-[var(--color-text)] hover:text-[var(--color-text-secondary)] transition-colors"
210+
title={tShared('platforms.linkedin')}
211+
>
212+
<Linkedin className="w-3.5 h-3.5" />
213+
</a>
214+
) : (
215+
<span className="text-[var(--color-text-muted)] opacity-30">
216+
<Linkedin className="w-3.5 h-3.5" />
217+
</span>
218+
)}
219+
{communityUrls?.youtube ? (
220+
<a
221+
href={communityUrls.youtube}
222+
target="_blank"
223+
rel="noopener"
224+
className="text-[var(--color-text)] hover:text-[var(--color-text-secondary)] transition-colors"
225+
title={tShared('platforms.youtube')}
226+
>
227+
<Youtube className="w-3.5 h-3.5" />
228+
</a>
229+
) : (
230+
<span className="text-[var(--color-text-muted)] opacity-30">
231+
<Youtube className="w-3.5 h-3.5" />
232+
</span>
233+
)}
234+
</div>
235+
)
236+
},
237+
},
238+
{
239+
key: 'pricing-free',
240+
label: tPage('columns.freePlan'),
241+
render: (_: unknown, item: Record<string, unknown>) => {
242+
const pricing = item.pricing as PricingTier[]
243+
if (!pricing || pricing.length === 0) return '-'
244+
const freePlan = pricing.find(p => p.value === 0)
245+
return freePlan ? '✓' : '-'
246+
},
247+
},
248+
{
249+
key: 'pricing-min',
250+
label: tPage('columns.startingPrice'),
251+
render: (_: unknown, item: Record<string, unknown>) => {
252+
const pricing = item.pricing as PricingTier[]
253+
return <PricingSummaryValue pricing={pricing} boundary="min" />
254+
},
255+
},
256+
{
257+
key: 'pricing-max',
258+
label: tPage('columns.maxPrice'),
259+
render: (_: unknown, item: Record<string, unknown>) => {
260+
const pricing = item.pricing as PricingTier[]
261+
return <PricingSummaryValue pricing={pricing} boundary="max" />
262+
},
263+
},
264+
]
265+
266+
return (
267+
<>
268+
<Header />
269+
270+
<Breadcrumb
271+
items={[
272+
{ name: tShared('terms.aiCodingStack'), href: '/ai-coding-stack' },
273+
{ name: tShared('categories.plural.desktops'), href: '/desktops' },
274+
{ name: tShared('terms.comparison'), href: '/desktops/comparison' },
275+
]}
276+
/>
277+
278+
{/* Page Header */}
279+
<section className="pt-[var(--spacing-lg)] pb-[var(--spacing-md)]">
280+
<div className="max-w-8xl mx-auto px-[var(--spacing-md)]">
281+
<h1 className="text-3xl font-semibold tracking-[-0.03em] mb-[var(--spacing-sm)]">
282+
{tPage('desktops.title')}
283+
</h1>
284+
<p className="text-base text-[var(--color-text-secondary)] font-light">
285+
{tPage('desktops.subtitle')}
286+
</p>
287+
</div>
288+
</section>
289+
290+
{/* Comparison Table */}
291+
<section className="pb-[var(--spacing-xl)] border-b border-[var(--color-border)]">
292+
<div className="max-w-8xl mx-auto px-[var(--spacing-md)]">
293+
<ComparisonTable
294+
items={desktops as unknown as Record<string, unknown>[]}
295+
columns={columns}
296+
itemLinkPrefix={`/desktops`}
297+
nameColumnLabel={tShared('labels.name')}
298+
caption={tPage('desktops.title')}
299+
scrollHint={tPage('table.scrollHint')}
300+
/>
301+
</div>
302+
</section>
303+
304+
<Footer />
305+
</>
306+
)
307+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import type { Locale } from '@/i18n/config'
2+
import { generateComparisonMetadata } from '@/lib/metadata'
3+
import DesktopComparisonPageClient from './page.client'
4+
5+
type Props = {
6+
params: Promise<{ locale: string }>
7+
}
8+
9+
export async function generateMetadata({ params }: Props) {
10+
const { locale } = await params
11+
12+
return await generateComparisonMetadata({
13+
locale: locale as Locale,
14+
category: 'desktops',
15+
})
16+
}
17+
18+
export default async function DesktopComparisonPage({ params }: Props) {
19+
const { locale } = await params
20+
return <DesktopComparisonPageClient locale={locale} />
21+
}

src/app/[locale]/desktops/page.client.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,18 @@ export default function DesktopsPageClient({ locale }: Props) {
6363
<Header />
6464
<div className="max-w-8xl mx-auto px-[var(--spacing-md)] py-[var(--spacing-lg)]">
6565
<main className="w-full">
66-
<PageHeader title={tPage('title')} subtitle={tPage('subtitle')} />
66+
<PageHeader
67+
title={tPage('title')}
68+
subtitle={tPage('subtitle')}
69+
action={
70+
<Link
71+
href="/desktops/comparison"
72+
className="text-sm px-[var(--spacing-md)] py-[var(--spacing-xs)] border border-[var(--color-border)] hover:border-[var(--color-border-strong)] transition-colors"
73+
>
74+
{tShared('actions.compareAll')}
75+
</Link>
76+
}
77+
/>
6778
<StackTabs activeStack="desktops" locale={locale} />
6879
<FilterSortBar
6980
sortOrder={sortOrder}

src/app/sitemap.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export default function sitemap(): MetadataRoute.Sitemap {
5656
'/model-price-intelligence-index',
5757
'/ides/comparison',
5858
'/clis/comparison',
59+
'/desktops/comparison',
5960
'/extensions/comparison',
6061
'/models/compare',
6162
]

src/lib/metadata/i18n-validation.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ export const PAGE_TRANSLATION_REQUIREMENTS: Record<string, PageTranslationRequir
195195
type: 'staticSimple',
196196
},
197197

198-
// ========== Comparison Pages (4个) ==========
198+
// ========== Comparison Pages (5个) ==========
199199
'/models/compare': {
200200
namespace: 'pages.comparison',
201201
requiredKeys: ['models.title', 'models.subtitle'] as const,
@@ -211,14 +211,19 @@ export const PAGE_TRANSLATION_REQUIREMENTS: Record<string, PageTranslationRequir
211211
requiredKeys: ['clis.title', 'clis.subtitle'] as const,
212212
type: 'comparison',
213213
},
214+
'/desktops/comparison': {
215+
namespace: 'pages.comparison',
216+
requiredKeys: ['desktops.title', 'desktops.subtitle'] as const,
217+
type: 'comparison',
218+
},
214219
'/extensions/comparison': {
215220
namespace: 'pages.comparison',
216221
requiredKeys: ['extensions.title', 'extensions.subtitle'] as const,
217222
type: 'comparison',
218223
},
219224
}
220225

221-
// 总计: 31 个页面的翻译需求
226+
// 总计: 32 个页面的翻译需求
222227
// - 有 meta 对象的静态页面: 9 个
223228
// - Articles & Docs 列表页: 2 个
224229
// - 分类列表页: 6 个

0 commit comments

Comments
 (0)