Skip to content

Commit

Permalink
redesign alternative pages and speed up tag listing pages
Browse files Browse the repository at this point in the history
  • Loading branch information
piotrkulpinski committed Jul 1, 2024
1 parent 2676cf2 commit 4a57ff6
Show file tree
Hide file tree
Showing 15 changed files with 417 additions and 88 deletions.
2 changes: 1 addition & 1 deletion app/components/Heading.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { type ElementType, type HTMLAttributes, forwardRef, isValidElement } fro
import { type VariantProps, cva, cx } from "~/utils/cva"

export const headingVariants = cva({
base: "text-foreground font-semibold tracking-tight",
base: "text-foreground font-semibold tracking-tight text-pretty",

variants: {
size: {
Expand Down
116 changes: 116 additions & 0 deletions app/components/records/ToolEntry.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import type { SerializeFrom } from "@remix-run/node"
import { Link, unstable_useViewTransitionState } from "@remix-run/react"
import type { HTMLAttributes } from "react"
import type { ToolMany } from "~/services.server/api"
import { Card } from "../Card"
import { FaviconImage } from "../Favicon"
import { H2 } from "../Heading"
import { Markdown } from "../Markdown"
import { Series } from "../Series"
import { ArrowUpRightIcon, GithubIcon } from "lucide-react"
import { Button } from "../Button"
import { updateUrlWithSearchParams } from "~/utils/queryString"
import posthog from "posthog-js"
import { cx } from "~/utils/cva"

type Tool = ToolMany | SerializeFrom<ToolMany>

type ToolEntryProps = HTMLAttributes<HTMLElement> & {
tool: Tool
}

export const ToolEntry = ({ className, tool, ...props }: ToolEntryProps) => {
const to = `/${tool.slug}`
const vt = unstable_useViewTransitionState(to)

return (
<div
className={cx("flex flex-col gap-4 md:gap-12 [counter-increment:alternatives]", className)}
style={{ viewTransitionName: vt ? `tool-${tool.id}` : undefined }}
{...props}
>
<div>
<Series
size="lg"
className="before:content-['#'_counter(alternatives)] before:absolute before:right-full before:-mr-3 before:font-semibold before:text-3xl before:opacity-25 max-lg:before:hidden"
>
<FaviconImage
src={tool.faviconUrl}
title={tool.name}
style={{ viewTransitionName: vt ? `tool-${tool.id}-favicon` : undefined }}
/>

<H2
className="!leading-snug"
style={{ viewTransitionName: vt ? `tool-${tool.id}-name` : undefined }}
>
<Link to={to} prefetch="intent" unstable_viewTransition className="hover:underline">
{tool.name}
</Link>
</H2>
</Series>

{tool.description && (
<Card.Description
style={{ viewTransitionName: vt ? `tool-${tool.id}-description` : undefined }}
className="mt-2 text-base w-full md:text-lg"
>
{tool.description}
</Card.Description>
)}
</div>

{tool.screenshotUrl && (
<Link to={to} prefetch="intent" unstable_viewTransition>
<img
key={tool.screenshotUrl}
src={tool.screenshotUrl}
alt={`Screenshot of ${tool.name} website`}
width={1280}
height={1024}
loading="eager"
className="aspect-video h-auto w-full rounded-md border object-cover object-top"
style={{ viewTransitionName: vt ? `tool-${tool.id}-screenshot` : undefined }}
/>
</Link>
)}

{tool.content && (
<Markdown style={{ viewTransitionName: vt ? `tool-${tool.id}-content` : undefined }}>
{tool.content}
</Markdown>
)}

<Series>
{tool.website && (
<Button
suffix={<ArrowUpRightIcon />}
onClick={() => posthog.capture("website_clicked", { url: tool.website })}
asChild
>
<a
href={updateUrlWithSearchParams(tool.website, { ref: "openalternative" })}
target="_blank"
rel="nofollow noreferrer"
>
View Website
</a>
</Button>
)}

{tool.repository && (
<Button
variant="secondary"
prefix={<GithubIcon />}
onClick={() => posthog.capture("repository_clicked", { url: tool.repository })}
asChild
>
<a href={tool.repository} target="_blank" rel="noreferrer nofollow">
GitHub Repository
</a>
</Button>
)}
</Series>
</div>
)
}
14 changes: 14 additions & 0 deletions app/components/records/ToolRecord.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,20 @@ export const ToolRecord = ({ className, tool, isRelated, ...props }: ToolRecordP
</Card.Description>
)}

{tool.screenshotUrl && (
<div
className="-mt-4 w-full"
style={{ viewTransitionName: vt ? `tool-${tool.id}-screenshot` : undefined }}
/>
)}

{tool.content && (
<div
className="-mt-4 w-full"
style={{ viewTransitionName: vt ? `tool-${tool.id}-content` : undefined }}
/>
)}

<Insights insights={insights} className="mt-auto" />
</Link>
</Card>
Expand Down
7 changes: 6 additions & 1 deletion app/routes/$slug.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -292,10 +292,15 @@ export default function ToolsPage() {
height={1024}
loading="eager"
className="aspect-video h-auto w-full rounded-md border object-cover object-top"
style={{ viewTransitionName: vt ? `tool-${tool.id}-screenshot` : undefined }}
/>
)}

{tool.content && <Markdown>{tool.content}</Markdown>}
{tool.content && (
<Markdown style={{ viewTransitionName: vt ? `tool-${tool.id}-content` : undefined }}>
{tool.content}
</Markdown>
)}

{(!!links?.length || !!categories.length) && (
<div className="grid grid-auto-fit-sm gap-x-6 gap-y-10 w-full">
Expand Down
7 changes: 6 additions & 1 deletion app/routes/about.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { type MetaFunction, json } from "@remix-run/node"
import { useLoaderData } from "@remix-run/react"
import { BreadcrumbsLink } from "~/components/Breadcrumbs"
import { Featured } from "~/components/Featured"
import { Intro } from "~/components/Intro"
import { Markdown } from "~/components/Markdown"
import { SITE_NAME, SITE_TAGLINE, SITE_URL } from "~/utils/constants"
import { getMetaTags } from "~/utils/meta"

export const handle = {
breadcrumb: () => <BreadcrumbsLink to="/about" label="About Us" />,
}

export const meta: MetaFunction<typeof loader> = ({ matches, data, location }) => {
const { title, description } = data?.meta || {}

Expand Down Expand Up @@ -57,7 +62,7 @@ export default function AboutPage() {
## About the Author
I’m a software developer and entrepreneur. I’ve been building web applications for over 10 years. I’m passionate about open source software and I love to contribute to the community in any way I can.
I’m a software developer and entrepreneur. I’ve been building web applications for over 15 years. I’m passionate about open source software and I love to contribute to the community in any way I can.
Some of my other projects:
Expand Down
147 changes: 132 additions & 15 deletions app/routes/alternatives.$slug.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,28 @@
import { type LoaderFunctionArgs, type MetaFunction, json } from "@remix-run/node"
import { Category } from "@prisma/client"
import {
HeadersFunction,
type LoaderFunctionArgs,
type MetaFunction,
SerializeFrom,
json,
} from "@remix-run/node"
import { Link, useLoaderData } from "@remix-run/react"
import { ArrowUpRightIcon } from "lucide-react"
import { Fragment, ReactNode } from "react"
import { BackButton } from "~/components/BackButton"
import { BreadcrumbsLink } from "~/components/Breadcrumbs"
import { Button } from "~/components/Button"
import { Card } from "~/components/Card"
import { Favicon } from "~/components/Favicon"
import { Grid } from "~/components/Grid"
import { H3 } from "~/components/Heading"
import { Intro } from "~/components/Intro"
import { ToolRecord } from "~/components/records/ToolRecord"
import { Prose } from "~/components/Prose"
import { ToolEntry } from "~/components/records/ToolEntry"
import { type AlternativeOne, alternativeOnePayload } from "~/services.server/api"
import { prisma } from "~/services.server/prisma"
import { JSON_HEADERS } from "~/utils/constants"
import { getMetaTags } from "~/utils/meta"
import { combineServerTimings, makeTimings, time } from "~/utils/timing.server"

export const handle = {
breadcrumb: (data?: { alternative: AlternativeOne }) => {
Expand All @@ -36,26 +45,84 @@ export const meta: MetaFunction<typeof loader> = ({ matches, data, location }) =
})
}

export const headers: HeadersFunction = ({ loaderHeaders, parentHeaders }) => {
return {
"Server-Timing": combineServerTimings(parentHeaders, loaderHeaders),
}
}

export const loader = async ({ params: { slug } }: LoaderFunctionArgs) => {
const timings = makeTimings("alternative loader")

try {
const alternative = await prisma.alternative.findUniqueOrThrow({
where: { slug },
include: alternativeOnePayload,
})
const [alternative, tools] = await Promise.all([
time(
() =>
prisma.alternative.findUniqueOrThrow({
where: { slug },
include: alternativeOnePayload,
}),
{ type: "find alternative", timings },
),

time(
() =>
prisma.tool.findMany({
where: {
alternatives: { some: { alternative: { slug } } },
publishedAt: { lte: new Date() },
},
include: { categories: { include: { category: true } } },
orderBy: [{ isFeatured: "desc" }, { score: "desc" }],
}),
{ type: "find tools", timings },
),
])

const meta = {
title: `Best Open Source ${alternative.name} Alternatives`,
description: `A collection of the best open source ${alternative.name} alternatives. Find the best alternatives for ${alternative.name} that are open source and free to use/self-hostable.`,
}

return json({ meta, alternative }, { headers: JSON_HEADERS })
return json(
{ meta, alternative, tools },
{ headers: { "Server-Timing": timings.toString(), ...JSON_HEADERS } },
)
} catch {
throw json(null, { status: 404, statusText: "Not Found" })
}
}

export default function AlternativesPage() {
const { meta, alternative } = useLoaderData<typeof loader>()
const { meta, alternative, tools } = useLoaderData<typeof loader>()

const categories = tools.reduce<
Record<string, { count: number; category: SerializeFrom<Category> }>
>((acc, { categories }) => {
categories.forEach(({ category }) => {
if (!acc[category.name]) {
acc[category.name] = { count: 0, category }
}
acc[category.name].count += 1
})

return acc
}, {})

const bestAlternatives: ReactNode[] = tools.slice(0, 5).map(tool => (
<Link to={`/${tool.slug}`} prefetch="intent" unstable_viewTransition>
{tool.name}
</Link>
))

const popularCategories = Object.values(categories)
.sort((a, b) => b.count - a.count)
.slice(0, 3)
.map(({ category }) => (
<Link to={`/categories/${category.slug}`} unstable_viewTransition>
{category.label || category.name}
</Link>
))

return (
<>
Expand All @@ -64,7 +131,55 @@ export default function AlternativesPage() {
description={`Find the best alternatives to ${alternative.name} that are open source and free to use/self-hostable.`}
/>

<Grid>
<div className="grid items-start gap-6 md:grid-cols-3">
{!!tools.length && (
<Prose className="order-last md:order-first md:col-span-2">
<p>
The best open source alternative to {alternative.name} is {bestAlternatives.shift()}.
If that doesn't suit you, we've compiled a ranked list of other open source{" "}
{alternative.name} alternatives to help you find a suitable replacement.
{!!bestAlternatives.length && (
<>
{" "}
Other interesting open source
{bestAlternatives.length === 1
? ` alternative to ${alternative.name} is `
: ` alternatives to ${alternative.name} are: `}
{bestAlternatives.map((alt, index) => (
<Fragment key={index}>
{index > 0 && index !== bestAlternatives.length - 1 && ", "}
{index > 0 && index === bestAlternatives.length - 1 && " and "}
{alt}
</Fragment>
))}
.
</>
)}
</p>

{!!popularCategories.length && (
<p>
{alternative.name} alternatives are mainly {popularCategories.shift()}
{!!popularCategories.length && (
<>
{" "}
but may also be{" "}
{popularCategories.map((category, index) => (
<Fragment key={index}>
{index > 0 && index !== popularCategories.length - 1 && ", "}
{index > 0 && index === popularCategories.length - 1 && " or "}
{category}
</Fragment>
))}
</>
)}
. Filter by these if you want a narrower list of alternatives or looking for a
specific functionality of {alternative.name}.
</p>
)}
</Prose>
)}

<Card className="group/button bg-background" asChild>
<Link to={alternative.website} target="_blank" rel="noopener noreferrer nofollow">
<Card.Header>
Expand All @@ -90,15 +205,17 @@ export default function AlternativesPage() {
</Button>
</Link>
</Card>
</div>

{alternative.tools.map(({ tool }) => (
<ToolRecord key={tool.id} tool={tool} />
<div className="flex flex-col items-start gap-12 max-w-2xl [counter-reset:alternatives]">
{tools.map(tool => (
<ToolEntry key={tool.id} tool={tool} />
))}

{!alternative.tools?.length && (
<p className="col-span-full">No Open Source alternatives found.</p>
{!tools?.length && (
<p className="col-span-full">No Open Source alternatives to {alternative.name} found.</p>
)}
</Grid>
</div>

<BackButton to="/alternatives" />
</>
Expand Down
Loading

0 comments on commit 4a57ff6

Please sign in to comment.