555 lines
19 KiB
TypeScript
555 lines
19 KiB
TypeScript
'use client'
|
|
|
|
import { Button } from '@/components/button'
|
|
import { Container } from '@/components/container'
|
|
import { Footer } from '@/components/footer'
|
|
import { Gradient, GradientBackground } from '@/components/gradient'
|
|
import { Link } from '@/components/link'
|
|
import { Mark } from '@/components/logo'
|
|
import { Navbar } from '@/components/navbar'
|
|
import { Heading, Lead, Subheading } from '@/components/text'
|
|
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'
|
|
import {
|
|
CheckIcon,
|
|
ChevronUpDownIcon,
|
|
MinusIcon,
|
|
} from '@heroicons/react/16/solid'
|
|
import { useSearchParams } from 'next/navigation'
|
|
import type React from 'react'
|
|
function DropLogo() {
|
|
return (
|
|
<div className="relative -mb-1 inline-flex items-center justify-center gap-x-1">
|
|
<svg
|
|
aria-hidden="true"
|
|
viewBox="0 0 418 42"
|
|
className="absolute inset-0 h-full w-full scale-75 fill-blue-300/30"
|
|
preserveAspectRatio="none"
|
|
>
|
|
<path d="M203.371.916c-26.013-2.078-76.686 1.963-124.73 9.946L67.3 12.749C35.421 18.062 18.2 21.766 6.004 25.934 1.244 27.561.828 27.778.874 28.61c.07 1.214.828 1.121 9.595-1.176 9.072-2.377 17.15-3.92 39.246-7.496C123.565 7.986 157.869 4.492 195.942 5.046c7.461.108 19.25 1.696 19.17 2.582-.107 1.183-7.874 4.31-25.75 10.366-21.992 7.45-35.43 12.534-36.701 13.884-2.173 2.308-.202 4.407 4.442 4.734 2.654.187 3.263.157 15.593-.78 35.401-2.686 57.944-3.488 88.365-3.143 46.327.526 75.721 2.23 130.788 7.584 19.787 1.924 20.814 1.98 24.557 1.332l.066-.011c1.201-.203 1.53-1.825.399-2.335-2.911-1.31-4.893-1.604-22.048-3.261-57.509-5.556-87.871-7.36-132.059-7.842-23.239-.254-33.617-.116-50.627.674-11.629.54-42.371 2.494-46.696 2.967-2.359.259 8.133-3.625 26.504-9.81 23.239-7.825 27.934-10.149 28.304-14.005.417-4.348-3.529-6-16.878-7.066Z" />
|
|
</svg>
|
|
<Mark aria-hidden="true" className="h-6" />
|
|
<span className="font-display text-xl font-bold text-blue-400 uppercase">
|
|
Drop
|
|
</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function GameVaultLogo() {
|
|
return (
|
|
<div className="inline-flex items-center gap-x-2 text-xl font-bold">
|
|
<img src="/icons/gamevault.png" alt="GameVault Logo" className="size-8" />
|
|
<span className="relative whitespace-nowrap text-purple-900">
|
|
<svg
|
|
aria-hidden="true"
|
|
viewBox="0 0 418 42"
|
|
className="absolute top-2/3 left-0 h-[0.58em] w-full fill-purple-900 opacity-30"
|
|
preserveAspectRatio="none"
|
|
>
|
|
<path d="M203.371.916c-26.013-2.078-76.686 1.963-124.73 9.946L67.3 12.749C35.421 18.062 18.2 21.766 6.004 25.934 1.244 27.561.828 27.778.874 28.61c.07 1.214.828 1.121 9.595-1.176 9.072-2.377 17.15-3.92 39.246-7.496C123.565 7.986 157.869 4.492 195.942 5.046c7.461.108 19.25 1.696 19.17 2.582-.107 1.183-7.874 4.31-25.75 10.366-21.992 7.45-35.43 12.534-36.701 13.884-2.173 2.308-.202 4.407 4.442 4.734 2.654.187 3.263.157 15.593-.78 35.401-2.686 57.944-3.488 88.365-3.143 46.327.526 75.721 2.23 130.788 7.584 19.787 1.924 20.814 1.98 24.557 1.332l.066-.011c1.201-.203 1.53-1.825.399-2.335-2.911-1.31-4.893-1.604-22.048-3.261-57.509-5.556-87.871-7.36-132.059-7.842-23.239-.254-33.617-.116-50.627.674-11.629.54-42.371 2.494-46.696 2.967-2.359.259 8.133-3.625 26.504-9.81 23.239-7.825 27.934-10.149 28.304-14.005.417-4.348-3.529-6-16.878-7.066Z"></path>
|
|
</svg>
|
|
<span className="relative">GameVault</span>
|
|
</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function GameVaultPlus() {
|
|
return (
|
|
<div className="inline-flex items-center gap-x-1 rounded-full bg-gray-100 px-2 py-1 text-xs">
|
|
<img
|
|
src="/icons/gamevault-plus.png"
|
|
alt="GameVault+ icon"
|
|
className="size-4"
|
|
/>
|
|
GameVault+
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ComingSoon() {
|
|
return (
|
|
<div className="inline-flex items-center gap-x-1 rounded-full bg-gray-100 px-2 py-1 text-xs">
|
|
coming soon™
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function RomMLogo() {
|
|
return (
|
|
<div className={'inline-flex items-center gap-x-2'}>
|
|
<img src="/icons/rommicon.svg" className="size-8" alt="RomM icon" />
|
|
<img src="/icons/rommmark.png" className="h-4" alt="RomM workmark" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const projects: Array<{
|
|
name: string
|
|
slug: string
|
|
logo: () => React.JSX.Element
|
|
description: string
|
|
href: string
|
|
highlights: Array<{ description: string; disabled?: boolean; paid?: boolean }>
|
|
features: {
|
|
[key: string]: {
|
|
[key: string]: number | string | boolean | (() => React.JSX.Element)
|
|
}
|
|
}
|
|
}> = [
|
|
{
|
|
name: 'Drop OSS' as const,
|
|
slug: 'drop',
|
|
logo: DropLogo,
|
|
description: 'An open-source and free self-hosted Steam alternative.',
|
|
href: '/',
|
|
highlights: [
|
|
{ description: 'First-class Linux support' },
|
|
{ description: 'Game versioning' },
|
|
{ description: 'Multiple metadata sources' },
|
|
{ description: 'Cloud saves & playtime tracking', disabled: true },
|
|
{
|
|
description: 'Overlay network for Steam-like play together',
|
|
disabled: true,
|
|
},
|
|
],
|
|
features: {
|
|
Library: {
|
|
'Multiple libraries': true,
|
|
'Versioned layout': true,
|
|
'Non-versioned layout': true,
|
|
'Installer/setup games': true,
|
|
'Portable games': true,
|
|
'Archives support': '.zip',
|
|
},
|
|
Metadata: {
|
|
IGDB: true,
|
|
GiantBomb: true,
|
|
PCGamingWiki: true,
|
|
Manual: true,
|
|
},
|
|
Clients: {
|
|
Windows: true,
|
|
Linux: true,
|
|
macOS: true,
|
|
Android: ComingSoon,
|
|
},
|
|
Authentication: {
|
|
Simple: true,
|
|
SSO: true,
|
|
},
|
|
Cloud: {
|
|
'Cloud saves': ComingSoon,
|
|
'Steamworks-compatible networking': ComingSoon,
|
|
'Dedicated server discovery': ComingSoon,
|
|
},
|
|
'Client Features': {
|
|
'Proton compatible for Linux': true,
|
|
'Multi-server': ComingSoon,
|
|
'In-game overlay': ComingSoon,
|
|
},
|
|
Additional: {
|
|
'User collections': true,
|
|
'Server news': true,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: 'GameVault' as const,
|
|
slug: 'gamevault',
|
|
logo: GameVaultLogo,
|
|
description:
|
|
'A source-available, mature Steam-like experience for your home server.',
|
|
href: 'https://gamevau.lt/',
|
|
highlights: [
|
|
{ description: 'Native Windows app' },
|
|
{ description: 'Published on Microsoft Store' },
|
|
{ description: 'Playtime tracking' },
|
|
{ description: 'Cloud saves', paid: true },
|
|
{ description: 'Third-party integrations', paid: true },
|
|
],
|
|
features: {
|
|
Library: {
|
|
'Multiple libraries': 'Using Docker volumes or symlinks',
|
|
'Automatic import': true,
|
|
'Non-versioned layout': true,
|
|
'Installer/setup games': true,
|
|
'Portable games': true,
|
|
'Archives support':
|
|
'.7z .xz .bz2 .gz .tar .zip .wim .ar .arj .cab .chm .cpio .cramfs .dmg .ext .fat .gpt .hfs .ihex .iso .lzh .lzma .mbr .msi .nsis .ntfs .qcow2 .rar .rpm .squashfs .udf .uefi .vdi .vhd .vmdk .wim .xar .z'
|
|
.split(' ')
|
|
.sort()
|
|
.join(' '),
|
|
},
|
|
Metadata: {
|
|
IGDB: true,
|
|
Manual: true,
|
|
},
|
|
Clients: {
|
|
Windows: true,
|
|
Linux: 'Possible, unsupported',
|
|
macOS: 'Possible, unsupported',
|
|
},
|
|
Authentication: {
|
|
Simple: true,
|
|
SSO: true,
|
|
},
|
|
Cloud: {
|
|
'Cloud saves': GameVaultPlus,
|
|
},
|
|
'Client Features': {
|
|
Theming: GameVaultPlus,
|
|
'Multi-profile usage': GameVaultPlus,
|
|
'Playnite integration': GameVaultPlus,
|
|
'Steam integration': GameVaultPlus,
|
|
'Discord integration': GameVaultPlus,
|
|
},
|
|
Additional: {
|
|
'Parental controls': true,
|
|
'Server news': true,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: 'RomM' as const,
|
|
slug: 'romm',
|
|
logo: RomMLogo,
|
|
description:
|
|
'An open-source, self-hosted rom manager, with built-in large emulator support.',
|
|
href: 'https://romm.app/',
|
|
highlights: [
|
|
{ description: '400+ supported platforms' },
|
|
{ description: 'Web-based (EmulatorJS) emulation' },
|
|
{ description: 'Multiple metadata sources' },
|
|
{ description: 'Android app' },
|
|
{ description: 'Cloud sync', disabled: true },
|
|
],
|
|
features: {
|
|
Library: {
|
|
'Non-versioned layout': true,
|
|
},
|
|
Metadata: {
|
|
IGDB: true,
|
|
Hasheous: true,
|
|
SteamGridDB: true,
|
|
Retroachievements: true,
|
|
PlayMatch: true,
|
|
ScreenScraper: true,
|
|
LaunchBox: true,
|
|
},
|
|
Clients: {
|
|
Browser: true,
|
|
Android: true,
|
|
muOS: true,
|
|
},
|
|
Authentication: {
|
|
Simple: true,
|
|
SSO: true,
|
|
},
|
|
Cloud: {
|
|
'Cloud saves': ComingSoon,
|
|
},
|
|
},
|
|
},
|
|
]
|
|
|
|
function Header() {
|
|
return (
|
|
<Container className="mt-16">
|
|
<Heading as="h1" className="leading-12">
|
|
What's the{' '}
|
|
<span className="rounded-xl bg-zinc-900 px-3 py-2 font-mono text-zinc-300">
|
|
git diff
|
|
</span>
|
|
?
|
|
</Heading>
|
|
<Lead className="mt-6 max-w-3xl">
|
|
A breakdown between the different projects available to you, put
|
|
together by the Drop OSS project.
|
|
</Lead>
|
|
<Subheading className="mt-2">Last updated 02-09-2025</Subheading>
|
|
</Container>
|
|
)
|
|
}
|
|
|
|
function PricingCards() {
|
|
return (
|
|
<div className="relative py-24">
|
|
<Gradient className="absolute inset-x-2 top-48 bottom-0 rounded-4xl ring-1 ring-black/5 ring-inset" />
|
|
<Container className="relative">
|
|
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
|
|
{projects.map((tier, tierIndex) => (
|
|
<PricingCard key={tierIndex} tier={tier} />
|
|
))}
|
|
</div>
|
|
</Container>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function PricingCard({ tier }: { tier: (typeof projects)[number] }) {
|
|
return (
|
|
<div className="-m-2 grid grid-cols-1 rounded-4xl shadow-[inset_0_0_2px_1px_#ffffff4d] ring-1 ring-black/5 max-lg:mx-auto max-lg:w-full max-lg:max-w-md">
|
|
<div className="grid grid-cols-1 rounded-4xl p-2 shadow-md shadow-black/5">
|
|
<div className="rounded-3xl bg-white p-10 pb-9 shadow-2xl ring-1 ring-black/5">
|
|
<div className="flex w-full items-center justify-center pb-8">
|
|
{tier.logo()}
|
|
</div>
|
|
|
|
<Subheading>{tier.name}</Subheading>
|
|
<p className="mt-2 text-sm/6 text-gray-950/75">{tier.description}</p>
|
|
<div className="mt-8">
|
|
<Button href={tier.href}>Learn more →</Button>
|
|
</div>
|
|
<div className="mt-8">
|
|
<h3 className="text-sm/6 font-medium text-gray-950">
|
|
Key features:
|
|
</h3>
|
|
<ul className="mt-3 space-y-3">
|
|
{tier.highlights.map((props, featureIndex) => (
|
|
<FeatureItem key={featureIndex} {...props} />
|
|
))}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function PricingTable({
|
|
selectedTier: selectedProject,
|
|
}: {
|
|
selectedTier: (typeof projects)[number]
|
|
}) {
|
|
function onlyUnique<T>(value: T, index: number, array: Array<T>) {
|
|
return array.indexOf(value) === index
|
|
}
|
|
|
|
const sections = projects
|
|
.map((e) => Object.keys(e.features))
|
|
.flat()
|
|
.filter(onlyUnique)
|
|
const features: { [key: string]: string[] } = {}
|
|
for (const section of sections) {
|
|
const uniqueFeatures = projects
|
|
.filter((e) => e.features[section])
|
|
.map((e) => Object.keys(e.features[section]))
|
|
.flat()
|
|
.filter(onlyUnique)
|
|
features[section] = uniqueFeatures
|
|
}
|
|
return (
|
|
<Container className="py-24">
|
|
<table className="w-full text-left">
|
|
<caption className="sr-only">Pricing plan comparison</caption>
|
|
<colgroup>
|
|
<col className="w-3/5 sm:w-2/5" />
|
|
<col
|
|
data-selected={selectedProject === projects[0] ? true : undefined}
|
|
className="w-2/5 data-selected:table-column max-sm:hidden sm:w-1/5"
|
|
/>
|
|
<col
|
|
data-selected={selectedProject === projects[1] ? true : undefined}
|
|
className="w-2/5 data-selected:table-column max-sm:hidden sm:w-1/5"
|
|
/>
|
|
<col
|
|
data-selected={selectedProject === projects[2] ? true : undefined}
|
|
className="w-2/5 data-selected:table-column max-sm:hidden sm:w-1/5"
|
|
/>
|
|
</colgroup>
|
|
<thead>
|
|
<tr className="max-sm:hidden">
|
|
<td className="p-0" />
|
|
{projects.map((project) => (
|
|
<th
|
|
key={project.slug}
|
|
scope="col"
|
|
data-selected={selectedProject === project ? true : undefined}
|
|
className="p-0 data-selected:table-cell max-sm:hidden"
|
|
>
|
|
<Subheading as="div">{project.name}</Subheading>
|
|
</th>
|
|
))}
|
|
</tr>
|
|
<tr className="sm:hidden">
|
|
<td className="p-0">
|
|
<div className="relative inline-block">
|
|
<Menu>
|
|
<MenuButton className="flex items-center justify-between gap-2 font-medium">
|
|
{selectedProject.name}
|
|
<ChevronUpDownIcon className="size-4 fill-gray-900" />
|
|
</MenuButton>
|
|
<MenuItems
|
|
anchor="bottom start"
|
|
className="min-w-(--button-width) rounded-lg bg-white p-1 shadow-lg ring-1 ring-gray-200 [--anchor-gap:6px] [--anchor-offset:-4px] [--anchor-padding:10px]"
|
|
>
|
|
{projects.map((tier) => (
|
|
<MenuItem key={tier.slug}>
|
|
<Link
|
|
scroll={false}
|
|
href={`/comparison?tier=${tier.slug}`}
|
|
data-selected={
|
|
tier === selectedProject ? true : undefined
|
|
}
|
|
className="group flex items-center gap-2 rounded-md px-2 py-1 data-focus:bg-gray-200"
|
|
>
|
|
{tier.name}
|
|
<CheckIcon className="hidden size-4 group-data-selected:block" />
|
|
</Link>
|
|
</MenuItem>
|
|
))}
|
|
</MenuItems>
|
|
</Menu>
|
|
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center">
|
|
<ChevronUpDownIcon className="size-4 fill-gray-900" />
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td colSpan={3} className="p-0 text-right">
|
|
<Button variant="outline" href={selectedProject.href}>
|
|
Get started
|
|
</Button>
|
|
</td>
|
|
</tr>
|
|
<tr className="max-sm:hidden">
|
|
<th className="p-0" scope="row">
|
|
<span className="sr-only">Get started</span>
|
|
</th>
|
|
{projects.map((tier) => (
|
|
<td
|
|
key={tier.slug}
|
|
data-selected={selectedProject === tier ? true : undefined}
|
|
className="px-0 pt-4 pb-0 data-selected:table-cell max-sm:hidden"
|
|
>
|
|
<Button variant="outline" href={tier.href}>
|
|
Get started
|
|
</Button>
|
|
</td>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
{sections.map((section) => (
|
|
<tbody key={section} className="group">
|
|
<tr>
|
|
<th
|
|
scope="colgroup"
|
|
colSpan={4}
|
|
className="px-0 pt-10 pb-0 group-first-of-type:pt-5"
|
|
>
|
|
<div className="-mx-4 rounded-lg bg-gray-50 px-4 py-3 text-sm/6 font-semibold">
|
|
{section}
|
|
</div>
|
|
</th>
|
|
</tr>
|
|
{features[section].map((name) => (
|
|
<tr
|
|
key={name}
|
|
className="border-b border-gray-100 last:border-none"
|
|
>
|
|
<th
|
|
scope="row"
|
|
className="px-0 py-4 text-sm/6 font-normal text-gray-600"
|
|
>
|
|
{name}
|
|
</th>
|
|
{projects.map((project) => {
|
|
let value = project.features[section]?.[name]
|
|
|
|
return (
|
|
<td
|
|
key={project.slug}
|
|
data-selected={
|
|
selectedProject === project ? true : undefined
|
|
}
|
|
className="p-4 data-selected:table-cell max-sm:hidden"
|
|
>
|
|
{typeof value === 'function' ? (
|
|
<>{value()}</>
|
|
) : value === true ? (
|
|
<>
|
|
<CheckIcon className="size-4 fill-green-600" />
|
|
<span className="sr-only">
|
|
Included in {project.name}
|
|
</span>
|
|
</>
|
|
) : value === false || value === undefined ? (
|
|
<>
|
|
<MinusIcon className="size-4 fill-gray-400" />
|
|
<span className="sr-only">
|
|
Not included in {project.name}
|
|
</span>
|
|
</>
|
|
) : (
|
|
<div className="text-xs">{value}</div>
|
|
)}
|
|
</td>
|
|
)
|
|
})}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
))}
|
|
</table>
|
|
</Container>
|
|
)
|
|
}
|
|
|
|
function FeatureItem({
|
|
description,
|
|
disabled = false,
|
|
paid = false,
|
|
}: {
|
|
description: string
|
|
disabled?: boolean
|
|
paid?: boolean
|
|
}) {
|
|
return (
|
|
<li
|
|
data-disabled={disabled ? true : undefined}
|
|
className="flex items-center gap-4 text-sm/6 text-gray-950/75 data-disabled:text-gray-950/25"
|
|
>
|
|
<span className="inline-flex h-6 items-center self-start">
|
|
<PlusIcon className="size-3.75 shrink-0 fill-gray-950/25" />
|
|
</span>
|
|
{disabled && <span className="sr-only">Coming soon:</span>}
|
|
{description}
|
|
{disabled && (
|
|
<span className="text-right text-xs text-blue-300">
|
|
coming soon™
|
|
</span>
|
|
)}
|
|
{paid && <GameVaultPlus />}
|
|
</li>
|
|
)
|
|
}
|
|
|
|
function PlusIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
|
return (
|
|
<svg viewBox="0 0 15 15" aria-hidden="true" {...props}>
|
|
<path clipRule="evenodd" d="M8 0H7v7H0v1h7v7h1V8h7V7H8V0z" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
export default function Pricing() {
|
|
let params = useSearchParams()
|
|
let tier =
|
|
typeof params.get('tier') === 'string'
|
|
? projects.find(({ slug }) => slug === params.get('tier'))!
|
|
: projects[0]
|
|
|
|
return (
|
|
<main className="overflow-hidden">
|
|
<GradientBackground />
|
|
<Container>
|
|
<Navbar />
|
|
</Container>
|
|
<Header />
|
|
<PricingCards />
|
|
<PricingTable selectedTier={tier} />
|
|
<Footer />
|
|
</main>
|
|
)
|
|
}
|