feat(docs): redesign the marketing landing page

Rebuild the home page hero, feature sections, and footer with a new layout, integration logos component, and an app screenshot. Drop the scroll indicator in favour of the new structure.
This commit is contained in:
jake committed 2026-10-06 21:47:43 -04:00
1 parent 8084561a18
commit ac76898db4
8 files changed
+476 -241

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+35 -18
View File
@@ -1,46 +1,63 @@
import { HomeLayout } from "fumadocs-ui/layouts/home";
import Link from "next/link";
import { SofaLogo } from "@/components/sofa-logo";
import { TmdbLogo } from "@/components/tmdb-logo";
import { baseOptions } from "@/lib/layout.shared";
const footerLinks = [
{ text: "Documentation", href: "/docs" },
{ text: "API reference", href: "/docs/api" },
{ text: "GitHub", href: "https://github.com/jakejarvis/sofa", external: true },
{ text: "Privacy", href: "/privacy" },
];
export default function Layout({ children }: LayoutProps<"/">) {
return (
<HomeLayout {...baseOptions()}>
{children}
<footer className="border-fd-border border-t px-6 py-8">
<div className="text-fd-muted-foreground mx-auto flex max-w-5xl flex-col items-center gap-4 text-center text-xs">
<div className="flex flex-wrap items-center justify-center gap-x-2 gap-y-1">
<footer className="border-fd-border border-t">
<div className="text-fd-muted-foreground mx-auto flex w-full max-w-6xl flex-col gap-8 px-6 py-10 text-sm md:flex-row md:items-start md:justify-between">
<div>
<p className="text-fd-foreground flex items-center gap-2">
<SofaLogo className="size-5" />
<span className="font-display text-lg leading-none">Sofa</span>
</p>
<p className="mt-3">Free and open source under the MIT license.</p>
</div>
<nav aria-label="Footer">
<ul className="flex flex-wrap gap-x-6 gap-y-2">
{footerLinks.map((link) => (
<li key={link.text}>
{link.external ? (
<a
href="https://github.com/jakejarvis/sofa"
href={link.href}
target="_blank"
rel="noopener noreferrer"
className="hover:text-fd-foreground"
>
GitHub
{link.text}
</a>
<span className="hidden sm:inline">&middot;</span>
<Link href="/docs" className="hover:text-fd-foreground">
Docs
</Link>
<span className="hidden sm:inline">&middot;</span>
<Link href="/privacy" className="hover:text-fd-foreground">
Privacy
) : (
<Link href={link.href} className="hover:text-fd-foreground">
{link.text}
</Link>
)}
</li>
))}
</ul>
</nav>
</div>
<div className="flex flex-col items-center gap-2">
<div className="text-fd-muted-foreground/80 mx-auto flex w-full max-w-6xl items-center gap-3 px-6 pb-10 text-xs">
<a
href="https://www.themoviedb.org/"
target="_blank"
rel="noopener noreferrer"
className="opacity-85 transition-opacity hover:opacity-100"
className="shrink-0 opacity-85 transition-opacity hover:opacity-100"
>
<TmdbLogo className="h-3" />
</a>
<p className="text-fd-muted-foreground/80 max-w-md">
This product uses the TMDB API but is not endorsed or certified by TMDB.
</p>
</div>
<p>This product uses the TMDB API but is not endorsed or certified by TMDB.</p>
</div>
</footer>
</HomeLayout>
+287 -150
View File
@@ -1,214 +1,351 @@
import {
ArrowRightLeft,
Container,
ExternalLinkIcon,
HardDrive,
InfoIcon,
ListChecks,
MonitorPlay,
Smartphone,
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { ScrollIndicator } from "@/components/scroll-indicator";
import { SofaLogo } from "@/components/sofa-logo";
import screenshot from "@/assets/screenshot.png";
import {
EmbyLogo,
JellyfinLogo,
LetterboxdLogo,
PlexLogo,
RadarrLogo,
SimklLogo,
SonarrLogo,
TraktLogo,
} from "@/components/integration-logos";
import { cn } from "@/lib/cn";
const features = [
const highlights = [
{
title: "Episode Tracking",
title: "Pick up where you left off",
description:
"Track progress at the episode level. Pick up any series exactly where you left off.",
icon: ListChecks,
"Check off episodes one at a time or a whole season at once. Continue Watching lines up the next episode of every show you’re partway through.",
},
{
title: "Media Server Sync",
description: "Automatically log watches from Plex, Jellyfin, or Emby via webhooks.",
icon: MonitorPlay,
},
{
title: "Sonarr & Radarr",
description: "Export your watchlist as import lists for your *arr stack.",
icon: ArrowRightLeft,
},
{
title: "Self-Hosted",
title: "Find something to watch",
description:
"Your data lives on your server. Single-file database, local image cache, built-in backups.",
icon: HardDrive,
"Get recommendations based on what you’ve rated highly, see what’s airing this week, and check which services are streaming a title.",
},
{
title: "Web + Mobile",
description:
"Native iOS and Android app alongside the web interface. Same account, synced everywhere.",
icon: Smartphone,
},
{
title: "One Container",
description: "Deploy with a single Docker image. No external database, no sidecar services.",
icon: Container,
title: "Take it with you",
description: (
<>
The iPhone and Android apps sign in to your own server. Get them from the{" "}
<a
href="https://apps.apple.com/us/app/sofa-tv-movie-tracker/id6760432427"
className="text-fd-foreground hover:text-fd-primary underline underline-offset-4"
>
App Store
</a>{" "}
or{" "}
<a
href="https://play.google.com/store/apps/details?id=com.jakejarvis.sofa"
className="text-fd-foreground hover:text-fd-primary underline underline-offset-4"
>
Google Play
</a>
.
</>
),
},
];
const steps = [
const credits = [
{
step: "1",
title: "Get a TMDB API token",
content: (
<p className="text-fd-muted-foreground text-sm">
Create a free account at{" "}
role: "Logs what you finish in",
note: "Through each server’s webhook. Plex and Emby need a premium plan; Jellyfin needs its Webhook plugin.",
names: [
{ name: "Plex", href: "/docs/integrations/plex", logo: PlexLogo },
{ name: "Jellyfin", href: "/docs/integrations/jellyfin", logo: JellyfinLogo },
{ name: "Emby", href: "/docs/integrations/emby", logo: EmbyLogo },
],
},
{
role: "Sends your watchlist to",
note: "As a custom import list, so anything you add gets downloaded on the next sync.",
names: [
{ name: "Sonarr", href: "/docs/integrations/sonarr", logo: SonarrLogo },
{ name: "Radarr", href: "/docs/integrations/radarr", logo: RadarrLogo },
],
},
{
role: "Imports your history from",
note: "Watches, ratings, and watchlist. Sign in to Trakt or Simkl, or upload a Letterboxd export.",
names: [
{ name: "Trakt", href: "/docs/integrations/import", logo: TraktLogo },
{ name: "Simkl", href: "/docs/integrations/import", logo: SimklLogo },
{ name: "Letterboxd", href: "/docs/integrations/import", logo: LetterboxdLogo },
],
},
];
// Mirrors docker-compose.yml at the repo root
const composeLines = [
"services:",
" sofa:",
" image: ghcr.io/jakejarvis/sofa:edge",
" container_name: sofa",
" restart: unless-stopped",
" ports:",
' - "3000:3000"',
" volumes:",
" - sofa-data:/data",
" environment:",
" - TMDB_API_READ_ACCESS_TOKEN=${TMDB_API_READ_ACCESS_TOKEN}",
" - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}",
" - BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:3000}",
"",
"volumes:",
" sofa-data:",
];
// `line` is the zero-based compose line a note sits beside; `span` is how many lines it covers
const composeNotes = [
{
line: 2,
span: 1,
title: "One image, nothing else to run",
description: "No separate database, cache, or worker. Built for amd64 and arm64.",
},
{
line: 7,
span: 2,
title: "All your data in one volume",
description: "The SQLite database, cached posters, and backups.",
},
{
line: 10,
span: 3,
title: "The three settings",
description: (
<>
A free{" "}
<a
href="https://www.themoviedb.org/settings/api"
target="_blank"
rel="noopener noreferrer"
className="text-fd-primary hover:text-fd-primary/80 underline underline-offset-4"
className="text-fd-foreground hover:text-fd-primary underline underline-offset-4"
>
themoviedb.org
<ExternalLinkIcon className="text-fd-muted-foreground ml-1 inline-block size-3 translate-y-[-2px]" />
TMDB read access token
</a>
</p>
),
},
{
step: "2",
title: "Set three environment variables",
content: (
<pre className="bg-fd-background/80 text-fd-muted-foreground mt-2 overflow-x-auto rounded-lg p-3 text-xs ring-1 ring-white/[0.06]">
<code>{`TMDB_API_READ_ACCESS_TOKEN=...
BETTER_AUTH_SECRET=...
BETTER_AUTH_URL=https://sofa.example.com`}</code>
</pre>
),
},
{
step: "3",
title: "Start the container",
content: (
<pre className="bg-fd-background/80 text-fd-muted-foreground mt-2 overflow-x-auto rounded-lg p-3 text-xs ring-1 ring-white/[0.06]">
<code>docker compose up -d</code>
</pre>
, a long random secret, and the address you’ll open Sofa at.
</>
),
},
];
const highlightedLines = new Set(
composeNotes.flatMap((note) => Array.from({ length: note.span }, (_, i) => note.line + i)),
);
function ComposeLine({ text }: { text: string }) {
const match = /^(\s*)(- )?([\w-]+:(?=\s|$))?(.*)$/.exec(text);
if (!match) return text;
const [, indent, dash, key, rest] = match;
return (
<>
{indent}
{dash && <span className="text-fd-muted-foreground/70">{dash}</span>}
{key && <span className="text-fd-muted-foreground">{key}</span>}
<span className="text-fd-foreground">{rest}</span>
</>
);
}
const buttonBase =
"inline-flex h-11 items-center rounded-md px-5 text-sm font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-fd-ring";
export default function HomePage() {
return (
<div className="relative overflow-x-clip">
{/* Ambient glows — positioned outside hero so they bleed into features */}
<div className="pointer-events-none absolute -top-32 -left-32 h-[500px] w-[500px] rounded-full bg-[oklch(0.8_0.14_65)] opacity-[0.07] blur-[120px]" />
<div className="pointer-events-none absolute top-[60vh] -right-24 h-[400px] w-[400px] rounded-full bg-[oklch(0.8_0.14_65)] opacity-[0.04] blur-[100px]" />
{/* ── Hero ── */}
<section className="relative flex min-h-[calc(100vh-3.5rem)] flex-col items-center justify-center px-6 pt-8 pb-16 text-center">
<div className="relative flex max-w-2xl flex-col items-center gap-8">
<p
className="animate-stagger-item text-fd-primary text-xs font-medium tracking-[0.3em] uppercase"
style={{ "--stagger-index": 0 } as React.CSSProperties}
>
Self-hosted movie & TV tracker
</p>
<div
className="animate-stagger-item"
style={{ "--stagger-index": 1 } as React.CSSProperties}
>
<SofaLogo className="text-fd-primary size-20 md:size-28" />
</div>
<h1
className="animate-stagger-item font-display text-3xl tracking-tight md:text-5xl"
style={{ "--stagger-index": 2 } as React.CSSProperties}
>
Your watchlist belongs to you.
<div className="overflow-x-clip">
<section className="mx-auto w-full max-w-6xl px-6 pt-14 md:pt-24">
<h1 className="font-display text-[2.75rem] leading-[1.04] tracking-[-0.01em] text-balance md:text-7xl md:leading-[1.02]">
Everything you’ve watched,
<br className="hidden sm:inline" /> on a server you own.
</h1>
<p
className="animate-stagger-item text-fd-muted-foreground max-w-md text-base leading-relaxed md:text-lg"
style={{ "--stagger-index": 3 } as React.CSSProperties}
>
Sofa lives on your own server and seamlessly plugs into your existing home media stack.
<p className="text-fd-muted-foreground mt-6 max-w-[36rem] text-lg leading-relaxed md:mt-8 md:text-xl md:leading-relaxed">
Sofa is a movie and TV tracker you run yourself, in one Docker container. Follow shows
episode by episode, log watches straight from your media server, and keep your history off
someone else’s servers.
</p>
<div
className="animate-stagger-item flex flex-wrap items-center justify-center gap-4"
style={{ "--stagger-index": 4 } as React.CSSProperties}
>
<div className="mt-8 flex flex-wrap items-center gap-3 md:mt-10">
<Link
href="/docs"
className="bg-fd-primary text-fd-primary-foreground hover:shadow-fd-primary/20 inline-flex h-11 items-center rounded-lg px-7 text-sm font-medium transition-shadow hover:shadow-lg"
className={cn(
buttonBase,
"bg-fd-primary text-fd-primary-foreground hover:bg-fd-primary/85",
)}
>
Get Started
Install Sofa
</Link>
<a
href="https://github.com/jakejarvis/sofa"
target="_blank"
rel="noopener noreferrer"
className="ring-fd-border hover:bg-fd-accent hover:ring-fd-primary/30 inline-flex h-11 items-center rounded-lg px-7 text-sm font-medium ring-1 transition-colors"
className={cn(buttonBase, "ring-fd-border hover:bg-fd-accent ring-1 ring-inset")}
>
GitHub
View on GitHub
</a>
</div>
</div>
<ScrollIndicator />
</section>
{/* ── Features ── */}
<section className="mx-auto w-full max-w-5xl px-6 py-24">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{features.map((feature, i) => (
<div
key={feature.title}
className="animate-stagger-item bg-fd-card/50 rounded-xl p-6 ring-1 ring-white/[0.06]"
style={{ "--stagger-index": i } as React.CSSProperties}
>
<feature.icon className="text-fd-primary mb-3 size-5" />
<h3 className="font-display mb-1.5 text-lg">{feature.title}</h3>
<p className="text-fd-muted-foreground text-sm leading-relaxed">
{feature.description}
<section className="mx-auto w-full max-w-6xl px-6 pt-14 md:pt-20">
<div className="relative isolate">
{/* The screenshot lights the page around it, like a TV in a dark room */}
<Image
src={screenshot}
alt=""
aria-hidden
sizes="128px"
className="screen-glow pointer-events-none absolute inset-0 -z-10 size-full select-none"
/>
<div className="screen-image overflow-hidden rounded-lg border border-white/10 md:rounded-xl">
<Image
src={screenshot}
alt="Sofa’s page for Better Call Saul: the series backdrop and poster, a Completed status, where to watch it, and the episode list for season one with every episode checked off."
loading="eager"
fetchPriority="high"
placeholder="blur"
sizes="(min-width: 1152px) 1104px, calc(100vw - 48px)"
/>
</div>
</div>
<div className="mt-12 grid gap-x-10 gap-y-8 md:mt-16 md:grid-cols-3">
{highlights.map((item) => (
<div key={item.title}>
<h2 className="text-fd-foreground text-base font-medium">{item.title}</h2>
<p className="text-fd-muted-foreground mt-2 text-[15px] leading-relaxed">
{item.description}
</p>
</div>
))}
</div>
</section>
{/* ── Quick Start ── */}
<section className="mx-auto w-full max-w-3xl px-6 pb-24">
<h2
className="animate-stagger-item font-display mb-8 text-center text-2xl md:text-3xl"
style={{ "--stagger-index": 0 } as React.CSSProperties}
<section
aria-labelledby="stack-heading"
className="mx-auto w-full max-w-4xl px-6 py-28 md:py-40"
>
Deploy in minutes
<h2
id="stack-heading"
className="font-display text-center text-4xl leading-tight text-balance md:text-5xl"
>
Works with the rest of your stack
</h2>
<div className="space-y-4">
{steps.map((s) => (
<dl className="mt-16 space-y-14 md:mt-20 md:space-y-16">
{credits.map((credit) => (
<div
key={s.step}
className="animate-stagger-item bg-fd-card/50 rounded-xl p-5 ring-1 ring-white/[0.06]"
style={{ "--stagger-index": Number(s.step) } as React.CSSProperties}
key={credit.role}
className="grid grid-cols-[minmax(0,1fr)_9.25rem] items-baseline gap-x-5 md:grid-cols-2 md:gap-x-10"
>
<div className="flex items-baseline gap-3">
<span className="bg-fd-primary text-fd-primary-foreground flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-semibold">
{s.step}
<dt className="text-right">
<span className="text-fd-foreground block text-sm md:text-base">{credit.role}</span>
<span className="text-fd-muted-foreground mt-2 ml-auto block max-w-[17rem] text-xs leading-relaxed md:text-sm md:leading-relaxed">
{credit.note}
</span>
<h3 className="font-display text-base">{s.title}</h3>
</div>
<div className="mt-2 pl-9">{s.content}</div>
</dt>
<dd>
<ul>
{credit.names.map((item) => (
<li key={item.name}>
<Link
href={item.href}
className="group font-display hover:text-fd-primary focus-visible:outline-fd-ring inline-flex items-baseline gap-[0.3em] rounded-sm text-2xl leading-[1.35] focus-visible:outline-2 focus-visible:outline-offset-4 md:text-[2.5rem] md:leading-[1.25]"
>
{/* Sized to the serif's cap height so each mark sits on the baseline like a capital */}
<item.logo className="text-fd-muted-foreground group-hover:text-fd-primary size-[0.72em] shrink-0 transition-colors" />
<span className="decoration-fd-primary/60 underline-offset-[0.2em] group-hover:underline">
{item.name}
</span>
</Link>
</li>
))}
</ul>
</dd>
</div>
))}
</div>
</dl>
</section>
<p className="text-fd-muted-foreground mx-auto mt-4 flex w-fit items-center gap-1 text-xs">
<InfoIcon className="inline-block size-3" />
The first registered account becomes admin.
<section
aria-labelledby="deploy-heading"
className="mx-auto w-full max-w-6xl px-6 pb-28 md:pb-40"
>
<h2
id="deploy-heading"
className="font-display text-4xl leading-tight text-balance md:text-5xl"
>
One container, three settings
</h2>
<p className="text-fd-muted-foreground mt-4 max-w-[36rem] text-lg leading-relaxed">
This is the whole deployment. Sofa keeps everything in a single SQLite file, so there’s no
database server to set up alongside it.
</p>
<div className="mt-6 text-center">
<div className="compose mt-10 grid gap-10 md:mt-14 lg:grid-cols-[minmax(0,1fr)_22rem] lg:gap-14">
<figure className="bg-fd-card ring-fd-border min-w-0 overflow-hidden rounded-lg ring-1">
<figcaption className="border-fd-border text-fd-muted-foreground flex h-10 items-center border-b px-4 font-mono text-xs">
docker-compose.yml
</figcaption>
<pre className="overflow-x-auto py-4 font-mono text-[13px]">
<code className="block min-w-max">
{composeLines.map((text, i) => (
<span
// oxlint-disable-next-line no-array-index-key -- static lines, never reordered
key={i}
className={cn(
"compose-line block border-l-2 border-transparent pr-6 pl-[calc(1rem-2px)]",
highlightedLines.has(i) && "border-fd-primary bg-fd-primary/[0.06]",
)}
>
<ComposeLine text={text} />
{text === "" && "​"}
</span>
))}
</code>
</pre>
</figure>
<ul className="relative space-y-6 lg:space-y-0">
{composeNotes.map((note) => (
<li
key={note.title}
className="compose-note lg:absolute lg:inset-x-0"
style={{ "--line": note.line } as React.CSSProperties}
>
<p className="text-fd-foreground text-[15px] font-medium">{note.title}</p>
<p className="text-fd-muted-foreground mt-1 text-sm leading-relaxed">
{note.description}
</p>
</li>
))}
</ul>
</div>
<p className="text-fd-muted-foreground mt-10 max-w-[40rem] text-[15px] leading-relaxed">
Start it with{" "}
<code className="bg-fd-card text-fd-foreground ring-fd-border rounded px-1.5 py-0.5 font-mono text-[13px] ring-1">
docker compose up -d
</code>
, open port 3000, and create your account. The first account becomes the admin.
</p>
<div className="mt-8 flex flex-wrap items-center gap-3">
<Link
href="/docs"
className="text-fd-primary hover:text-fd-primary/80 text-sm underline underline-offset-4"
className={cn(
buttonBase,
"bg-fd-primary text-fd-primary-foreground hover:bg-fd-primary/85",
)}
>
Read the full setup guide &rarr;
Read the setup guide
</Link>
<Link
href="/docs/configuration"
className={cn(buttonBase, "ring-fd-border hover:bg-fd-accent ring-1 ring-inset")}
>
See all settings
</Link>
</div>
</section>
+51 -21
View File
@@ -71,41 +71,71 @@
}
}
@layer utilities {
.animate-stagger-item {
animation: stagger-fade-in 0.35s ease-out both;
animation-delay: calc(var(--stagger-index, 0) * 0.04s);
}
.animate-scroll-bounce {
animation: scroll-bounce 2s ease-in-out 1.4s infinite;
}
/* Landing page: the screenshot comes on like a TV in a dark room and lights its surroundings */
.screen-image {
/* The screenshot is cut mid-list, so let it run off into the page */
mask-image: linear-gradient(to bottom, #000 84%, transparent);
animation: screen-on 1.1s cubic-bezier(0.2, 0.7, 0.2, 1) 0.15s both;
}
@keyframes stagger-fade-in {
.screen-glow {
opacity: 0.5;
filter: blur(72px) saturate(1.6);
transform: scale(1.04, 1.08) translateY(5%);
animation: screen-glow-on 1.8s ease-out 0.5s both;
}
@keyframes screen-on {
from {
opacity: 0;
transform: translateY(8px);
filter: brightness(0.25);
}
to {
opacity: 1;
transform: translateY(0);
filter: brightness(1);
}
}
@keyframes scroll-bounce {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(4px);
@keyframes screen-glow-on {
from {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.animate-stagger-item,
.animate-scroll-bounce {
.screen-image,
.screen-glow {
animation: none;
}
}
/* Landing page: compose file notes sit beside the line they describe */
.compose {
--compose-line: 1.75rem;
}
.compose-line {
line-height: var(--compose-line);
}
@media (width >= 64rem) {
.compose-note {
/* figcaption (2.5rem) + border + pre padding (1rem), then down to the line */
top: calc(3.5rem + 1px + var(--line) * var(--compose-line));
}
/* Leader from the code block to the note, centered on the first line */
.compose-note::before {
content: "";
position: absolute;
top: calc(var(--compose-line) / 2);
right: calc(100% + 0.75rem);
width: 2.25rem;
height: 1px;
background: var(--color-fd-border);
}
.compose-note > p:first-child {
line-height: var(--compose-line);
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 489 KiB

+93
View File
@@ -0,0 +1,93 @@
import type { SVGProps } from "react";
// Same marks the web app uses in Settings → Integrations and Settings → Import
export function PlexLogo(props: SVGProps<SVGSVGElement>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" aria-hidden="true" {...props}>
{/* Icon from CoreUI Brands by creativeLabs Łukasz Holeczek - https://creativecommons.org/publicdomain/zero/1.0/ */}
<path fill="currentColor" d="M15.527 0H6.24l10.239 16L6.24 32h9.287L25.76 16z" />
</svg>
);
}
export function JellyfinLogo(props: SVGProps<SVGSVGElement>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" {...props}>
{/* Icon from Simple Icons by Simple Icons Collaborators - https://github.com/simple-icons/simple-icons/blob/develop/LICENSE.md */}
<path
fill="currentColor"
d="M12 .002C8.826.002-1.398 18.537.16 21.666c1.56 3.129 22.14 3.094 23.682 0S15.177 0 12 0zm7.76 18.949c-1.008 2.028-14.493 2.05-15.514 0C3.224 16.9 9.92 4.755 12.003 4.755c2.081 0 8.77 12.166 7.759 14.196zM12 9.198c-1.054 0-4.446 6.15-3.93 7.189c.518 1.04 7.348 1.027 7.86 0c.511-1.027-2.874-7.19-3.93-7.19z"
/>
</svg>
);
}
export function EmbyLogo(props: SVGProps<SVGSVGElement>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" {...props}>
{/* Icon from Simple Icons by Simple Icons Collaborators - https://github.com/simple-icons/simple-icons/blob/develop/LICENSE.md */}
<path
fill="currentColor"
d="M11.041 0c-.007 0-1.456 1.43-3.219 3.176L4.615 6.352l.512.513l.512.512l-2.819 2.791L0 12.961l1.83 1.848l3.182 3.209l1.351 1.359l.508-.496c.28-.273.515-.498.524-.498c.008 0 1.266 1.264 2.794 2.808L12.97 24l.187-.182c.23-.225 5.007-4.95 5.717-5.656l.52-.516l-.502-.513c-.276-.282-.5-.52-.496-.53c.003-.009 1.264-1.26 2.802-2.783s2.8-2.776 2.803-2.785c.005-.012-3.617-3.684-6.107-6.193L17.65 4.6l-.505.505c-.279.278-.517.501-.53.497s-1.27-1.267-2.793-2.805A450 450 0 0 0 11.041 0M9.223 7.367c.091.038 7.951 4.608 7.957 4.627c.003.013-1.781 1.056-3.965 2.32a1000 1000 0 0 1-3.996 2.307c-.019.006-.026-1.266-.026-4.629c0-3.7.007-4.634.03-4.625"
/>
</svg>
);
}
export function SonarrLogo(props: SVGProps<SVGSVGElement>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" {...props}>
{/* Icon from Custom Brand Icons by Emanuele & rchiileea - https://github.com/elax46/custom-brand-icons/blob/main/LICENSE */}
<path
fill="currentColor"
d="m7.338 16.322l.165.159l-2.491 2.495l.13.129l2.493-2.498l.164.159l1.531-1.59l-.461-.444zm.106-8.651l-.161.161L8.855 9.4l.452-.453l-1.572-1.568l-.162.162L5 4.976q-.064.065-.127.132ZM5 4.976l-.128.131c.043-.043.083-.088.128-.131m0-.001l-.129.13l.128-.131ZM16.631 16.24l-1.648-1.64l-.451.453l1.647 1.64l.161-.162l2.533 2.621l.007-.006l.053-.052q.035-.035.067-.073L16.469 16.4ZM19 19.025q-.032.038-.067.073zm-.127.127l.007-.006zm-2.397-11.69l.062.065zl-.163-.162l-1.549 1.575l.455.449l1.549-1.572l-.163-.16l2.544-2.476q-.062-.067-.126-.132Zm2.672-2.346l-.127-.132q.065.065.127.132m.024-.023l-.128-.131l-.022.021l.127.132zm-7.156 4.139a2.66 2.66 0 0 0-1.941.8a2.62 2.62 0 0 0-.795 1.745v.384a3 3 0 0 0 .037.325a2.6 2.6 0 0 0 .763 1.434a2.4 2.4 0 0 0 .342.292a2.76 2.76 0 0 0 3.2 0a2.4 2.4 0 0 0 .279-.233l.059-.059a2.76 2.76 0 0 0 0-3.888a2.65 2.65 0 0 0-1.944-.8m6.917 9.862l-.053.052q.029-.025.053-.052M5.823 4.238l-.008.007Zm-.822.736l-.002.002zm.307 14.333l-.02-.018ZM7.505 12.1a5.64 5.64 0 0 0-1.426-4.257c-.806-.806-1.92-1.916-1.923-1.919a9.3 9.3 0 0 0-2.024 5.35a.13.13 0 0 0-.018.064Q2.1 11.653 2.1 12c0 .219 0 .439.014.658a10 10 0 0 0 .132 1.169a9.3 9.3 0 0 0 2.038 4.4c.007-.007.9-.9 1.75-1.754A5.63 5.63 0 0 0 7.505 12.1m4.527 4.587c-1.806 0-3.036.167-4.358 1.49a432 432 0 0 0-1.694 1.7q.125.098.255.189a9.43 9.43 0 0 0 5.774 1.846a9.5 9.5 0 0 0 5.784-1.846c.1-.068.189-.139.282-.211l-1.6-1.6c-1.428-1.431-2.56-1.568-4.443-1.568m-6.113 3.142L5.9 19.81Zm-.31-.252l-.023-.021Zm6.423-11.986a5.86 5.86 0 0 0 4.441-1.562c.753-.753 1.744-1.74 1.762-1.758a9.52 9.52 0 0 0-6.226-2.18a9.56 9.56 0 0 0-6.186 2.147L7.683 6.1a5.8 5.8 0 0 0 4.349 1.491m6.99-2.607v-.001l-.009-.009l-.002-.002l.002.002Zm-1.183 3.037c-1.2 1.2-1.3 2.238-1.3 4.075a5.7 5.7 0 0 0 1.48 4.358c.879.879 1.712 1.708 1.734 1.73A9.55 9.55 0 0 0 21.9 12a9.6 9.6 0 0 0-2.429-6.531q.217.25.414.5zm1.525 10.616l-.022.023zM19.233 5.2l-.084-.089z"
/>
</svg>
);
}
export function RadarrLogo(props: SVGProps<SVGSVGElement>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" {...props}>
{/* Icon from Custom Brand Icons by Emanuele & rchiileea - https://github.com/elax46/custom-brand-icons/blob/main/LICENSE */}
<path
fill="currentColor"
d="m8.06 16.01l7.199-4.113l-7.052-3.966Zm-1.028 3.82a2.96 2.96 0 0 1-2.5.294A3.37 3.37 0 0 0 8.648 21.3l10.136-5.876a1.73 1.73 0 0 0 .294-2.645zM19.225 9.106L8.8 3.083C6.738 1.614 3.359 2.5 3.359 6.168l.147 11.605c0 1.175.882 1.763 2.057 1.616L5.416 5.433c0-1.322.735-1.469 1.616-.881l11.752 6.61a2.9 2.9 0 0 1 1.47 2.2a3.307 3.307 0 0 0-1.029-4.256"
/>
</svg>
);
}
export function TraktLogo(props: SVGProps<SVGSVGElement>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" {...props}>
<path
fill="currentColor"
d="m15.082 15.107l-.73-.73l9.578-9.583a5 5 0 0 0-.115-.575L13.662 14.382l1.08 1.08l-.73.73l-1.81-1.81l11.22-11.238c-.075-.15-.155-.3-.25-.44L11.508 14.377l2.154 2.155l-.73.73l-7.193-7.199l.73-.73l4.309 4.31L22.546 1.86A5.62 5.62 0 0 0 18.362 0H5.635A5.637 5.637 0 0 0 0 5.634V18.37A5.63 5.63 0 0 0 5.635 24h12.732C21.477 24 24 21.48 24 18.37V6.19l-8.913 8.918zm-4.314-2.155L6.814 8.988l.73-.73l3.954 3.96zm1.075-1.084l-3.954-3.96l.73-.73l3.959 3.96zm9.853 5.688a4.14 4.14 0 0 1-4.14 4.14H6.438a4.144 4.144 0 0 1-4.139-4.14V6.438A4.14 4.14 0 0 1 6.44 2.3h10.387v1.04H6.438a3.1 3.1 0 0 0-3.099 3.1v11.11c0 1.71 1.39 3.105 3.1 3.105h11.117c1.71 0 3.1-1.395 3.1-3.105v-1.754h1.04v1.754z"
/>
</svg>
);
}
export function SimklLogo(props: SVGProps<SVGSVGElement>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" {...props}>
<path
fill="currentColor"
d="M3.84 0A3.83 3.83 0 0 0 0 3.84v16.32A3.83 3.83 0 0 0 3.84 24h16.32A3.83 3.83 0 0 0 24 20.16V3.84A3.83 3.83 0 0 0 20.16 0zm8.567 4.11q3.11 0 4.393.186q1.69.252 2.438.877q1.009.867 1.009 3.104q0 .241-.01.768h-4.234q-.021-.537-.074-.746q-.147-.615-.966-.692q-.725-.065-3.53-.066q-2.775 0-3.289.165q-.578.2-.578 1.024q0 .792.61.969q.514.143 4.633.275q3.73.11 4.76.275q1.04.165 1.654.495t.983.936q.556.892.557 2.873q0 2.212-.546 3.247q-.547 1.024-1.785 1.398q-1.219.374-6.71.374q-3.338 0-4.82-.187q-1.806-.22-2.593-.86q-.85-.684-1.008-1.93a10.5 10.5 0 0 1-.085-1.434v-.789H7.44q-.01 1.11.43 1.428q.232.151.525.203q.294.056 1.03.077a166 166 0 0 0 2.405.022q2.793-.01 3.234-.033q.83-.065 1.092-.23q.368-.242.368-1.077q0-.57-.231-.802q-.316-.318-1.503-.34q-.82 0-3.425-.132q-2.69-.133-3.488-.154q-2.08-.066-2.932-.505q-1.092-.56-1.429-1.91q-.189-.747-.189-1.956q0-2.547.925-3.59q.693-.79 2.102-1.044q1.271-.22 6.053-.22z"
/>
</svg>
);
}
export function LetterboxdLogo(props: SVGProps<SVGSVGElement>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" {...props}>
<path
fill="currentColor"
d="M8.224 14.352a4.45 4.45 0 0 1-3.775 2.092C1.992 16.444 0 14.454 0 12s1.992-4.444 4.45-4.444c1.592 0 2.988.836 3.774 2.092c-.427.682-.673 1.488-.673 2.352s.246 1.67.673 2.352M15.101 12c0-.864.247-1.67.674-2.352c-.786-1.256-2.183-2.092-3.775-2.092s-2.989.836-3.775 2.092c.427.682.674 1.488.674 2.352s-.247 1.67-.674 2.352c.786 1.256 2.183 2.092 3.775 2.092s2.989-.836 3.775-2.092A4.4 4.4 0 0 1 15.1 12zm4.45-4.444a4.45 4.45 0 0 0-3.775 2.092c.427.682.673 1.488.673 2.352s-.246 1.67-.673 2.352a4.45 4.45 0 0 0 3.775 2.092C22.008 16.444 24 14.454 24 12s-1.992-4.444-4.45-4.444z"
/>
</svg>
);
}
-52
View File
@@ -1,52 +0,0 @@
"use client";
import { ArrowDown } from "lucide-react";
import { useEffect, useRef, useState } from "react";
export function ScrollIndicator() {
const [shown, setShown] = useState(false);
const [scrolled, setScrolled] = useState(false);
const [fits, setFits] = useState(true);
const ref = useRef<HTMLDivElement>(null);
// Delayed entrance matching the stagger animation timing
useEffect(() => {
const timer = setTimeout(() => setShown(true), 800);
return () => clearTimeout(timer);
}, []);
// Hide if the hero content overlaps the indicator position
useEffect(() => {
const check = () => {
if (!ref.current) return;
const indicatorRect = ref.current.getBoundingClientRect();
const content = ref.current.previousElementSibling;
if (!content) return;
const contentBottom = content.getBoundingClientRect().bottom;
setFits(contentBottom < indicatorRect.top);
};
check();
window.addEventListener("resize", check, { passive: true });
return () => window.removeEventListener("resize", check);
}, []);
useEffect(() => {
const onScroll = () => {
setScrolled(window.scrollY > 32);
};
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
return (
<div
ref={ref}
className="text-fd-muted-foreground absolute bottom-9 flex flex-col items-center gap-1.5 transition-opacity duration-500"
style={{ opacity: shown && !scrolled && fits ? 1 : 0 }}
>
<ArrowDown className="animate-scroll-bounce size-7 stroke-2" aria-hidden="true" />
</div>
);
}