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 { HomeLayout } from "fumadocs-ui/layouts/home";
import Link from "next/link"; import Link from "next/link";
import { SofaLogo } from "@/components/sofa-logo";
import { TmdbLogo } from "@/components/tmdb-logo"; import { TmdbLogo } from "@/components/tmdb-logo";
import { baseOptions } from "@/lib/layout.shared"; 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<"/">) { export default function Layout({ children }: LayoutProps<"/">) {
return ( return (
<HomeLayout {...baseOptions()}> <HomeLayout {...baseOptions()}>
{children} {children}
<footer className="border-fd-border border-t px-6 py-8"> <footer className="border-fd-border border-t">
<div className="text-fd-muted-foreground mx-auto flex max-w-5xl flex-col items-center gap-4 text-center text-xs"> <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 className="flex flex-wrap items-center justify-center gap-x-2 gap-y-1"> <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 <a
href="https://github.com/jakejarvis/sofa" href={link.href}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="hover:text-fd-foreground" className="hover:text-fd-foreground"
> >
GitHub {link.text}
</a> </a>
<span className="hidden sm:inline">&middot;</span> ) : (
<Link href="/docs" className="hover:text-fd-foreground"> <Link href={link.href} className="hover:text-fd-foreground">
Docs {link.text}
</Link>
<span className="hidden sm:inline">&middot;</span>
<Link href="/privacy" className="hover:text-fd-foreground">
Privacy
</Link> </Link>
)}
</li>
))}
</ul>
</nav>
</div> </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 <a
href="https://www.themoviedb.org/" href="https://www.themoviedb.org/"
target="_blank" target="_blank"
rel="noopener noreferrer" 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" /> <TmdbLogo className="h-3" />
</a> </a>
<p className="text-fd-muted-foreground/80 max-w-md"> <p>This product uses the TMDB API but is not endorsed or certified by TMDB.</p>
This product uses the TMDB API but is not endorsed or certified by TMDB.
</p>
</div>
</div> </div>
</footer> </footer>
</HomeLayout> </HomeLayout>
+287 -150
View File
@@ -1,214 +1,351 @@
import { import Image from "next/image";
ArrowRightLeft,
Container,
ExternalLinkIcon,
HardDrive,
InfoIcon,
ListChecks,
MonitorPlay,
Smartphone,
} from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { ScrollIndicator } from "@/components/scroll-indicator"; import screenshot from "@/assets/screenshot.png";
import { SofaLogo } from "@/components/sofa-logo"; 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: description:
"Track progress at the episode level. Pick up any series exactly where you left off.", "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.",
icon: ListChecks,
}, },
{ {
title: "Media Server Sync", title: "Find something to watch",
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",
description: description:
"Your data lives on your server. Single-file database, local image cache, built-in backups.", "Get recommendations based on what you’ve rated highly, see what’s airing this week, and check which services are streaming a title.",
icon: HardDrive,
}, },
{ {
title: "Web + Mobile", title: "Take it with you",
description: description: (
"Native iOS and Android app alongside the web interface. Same account, synced everywhere.", <>
icon: Smartphone, 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"
title: "One Container", className="text-fd-foreground hover:text-fd-primary underline underline-offset-4"
description: "Deploy with a single Docker image. No external database, no sidecar services.", >
icon: Container, 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", role: "Logs what you finish in",
title: "Get a TMDB API token", note: "Through each server’s webhook. Plex and Emby need a premium plan; Jellyfin needs its Webhook plugin.",
content: ( names: [
<p className="text-fd-muted-foreground text-sm"> { name: "Plex", href: "/docs/integrations/plex", logo: PlexLogo },
Create a free account at{" "} { 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 <a
href="https://www.themoviedb.org/settings/api" href="https://www.themoviedb.org/settings/api"
target="_blank" target="_blank"
rel="noopener noreferrer" 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 TMDB read access token
<ExternalLinkIcon className="text-fd-muted-foreground ml-1 inline-block size-3 translate-y-[-2px]" />
</a> </a>
</p> , a long random secret, and the address you’ll open Sofa at.
), </>
},
{
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>
), ),
}, },
]; ];
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() { export default function HomePage() {
return ( return (
<div className="relative overflow-x-clip"> <div className="overflow-x-clip">
{/* Ambient glows — positioned outside hero so they bleed into features */} <section className="mx-auto w-full max-w-6xl px-6 pt-14 md:pt-24">
<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]" /> <h1 className="font-display text-[2.75rem] leading-[1.04] tracking-[-0.01em] text-balance md:text-7xl md:leading-[1.02]">
<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]" /> Everything you’ve watched,
<br className="hidden sm:inline" /> on a server you own.
{/* ── 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.
</h1> </h1>
<p className="text-fd-muted-foreground mt-6 max-w-[36rem] text-lg leading-relaxed md:mt-8 md:text-xl md:leading-relaxed">
<p Sofa is a movie and TV tracker you run yourself, in one Docker container. Follow shows
className="animate-stagger-item text-fd-muted-foreground max-w-md text-base leading-relaxed md:text-lg" episode by episode, log watches straight from your media server, and keep your history off
style={{ "--stagger-index": 3 } as React.CSSProperties} someone else’s servers.
>
Sofa lives on your own server and seamlessly plugs into your existing home media stack.
</p> </p>
<div className="mt-8 flex flex-wrap items-center gap-3 md:mt-10">
<div
className="animate-stagger-item flex flex-wrap items-center justify-center gap-4"
style={{ "--stagger-index": 4 } as React.CSSProperties}
>
<Link <Link
href="/docs" 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> </Link>
<a <a
href="https://github.com/jakejarvis/sofa" href="https://github.com/jakejarvis/sofa"
target="_blank" target="_blank"
rel="noopener noreferrer" 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> </a>
</div> </div>
</div>
<ScrollIndicator />
</section> </section>
{/* ── Features ── */} <section className="mx-auto w-full max-w-6xl px-6 pt-14 md:pt-20">
<section className="mx-auto w-full max-w-5xl px-6 py-24"> <div className="relative isolate">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"> {/* The screenshot lights the page around it, like a TV in a dark room */}
{features.map((feature, i) => ( <Image
<div src={screenshot}
key={feature.title} alt=""
className="animate-stagger-item bg-fd-card/50 rounded-xl p-6 ring-1 ring-white/[0.06]" aria-hidden
style={{ "--stagger-index": i } as React.CSSProperties} sizes="128px"
> className="screen-glow pointer-events-none absolute inset-0 -z-10 size-full select-none"
<feature.icon className="text-fd-primary mb-3 size-5" /> />
<h3 className="font-display mb-1.5 text-lg">{feature.title}</h3> <div className="screen-image overflow-hidden rounded-lg border border-white/10 md:rounded-xl">
<p className="text-fd-muted-foreground text-sm leading-relaxed"> <Image
{feature.description} 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> </p>
</div> </div>
))} ))}
</div> </div>
</section> </section>
{/* ── Quick Start ── */} <section
<section className="mx-auto w-full max-w-3xl px-6 pb-24"> aria-labelledby="stack-heading"
<h2 className="mx-auto w-full max-w-4xl px-6 py-28 md:py-40"
className="animate-stagger-item font-display mb-8 text-center text-2xl md:text-3xl"
style={{ "--stagger-index": 0 } as React.CSSProperties}
> >
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> </h2>
<div className="space-y-4"> <dl className="mt-16 space-y-14 md:mt-20 md:space-y-16">
{steps.map((s) => ( {credits.map((credit) => (
<div <div
key={s.step} key={credit.role}
className="animate-stagger-item bg-fd-card/50 rounded-xl p-5 ring-1 ring-white/[0.06]" className="grid grid-cols-[minmax(0,1fr)_9.25rem] items-baseline gap-x-5 md:grid-cols-2 md:gap-x-10"
style={{ "--stagger-index": Number(s.step) } as React.CSSProperties}
> >
<div className="flex items-baseline gap-3"> <dt className="text-right">
<span className="bg-fd-primary text-fd-primary-foreground flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-semibold"> <span className="text-fd-foreground block text-sm md:text-base">{credit.role}</span>
{s.step} <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> </span>
<h3 className="font-display text-base">{s.title}</h3> </dt>
</div> <dd>
<div className="mt-2 pl-9">{s.content}</div> <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>
))} ))}
</div> </dl>
</section>
<p className="text-fd-muted-foreground mx-auto mt-4 flex w-fit items-center gap-1 text-xs"> <section
<InfoIcon className="inline-block size-3" /> aria-labelledby="deploy-heading"
The first registered account becomes admin. 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> </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 <Link
href="/docs" 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> </Link>
</div> </div>
</section> </section>
+51 -21
View File
@@ -71,41 +71,71 @@
} }
} }
@layer utilities { /* Landing page: the screenshot comes on like a TV in a dark room and lights its surroundings */
.animate-stagger-item { .screen-image {
animation: stagger-fade-in 0.35s ease-out both; /* The screenshot is cut mid-list, so let it run off into the page */
animation-delay: calc(var(--stagger-index, 0) * 0.04s); 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;
.animate-scroll-bounce {
animation: scroll-bounce 2s ease-in-out 1.4s infinite;
}
} }
@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 { from {
opacity: 0; opacity: 0;
transform: translateY(8px); filter: brightness(0.25);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateY(0); filter: brightness(1);
} }
} }
@keyframes scroll-bounce { @keyframes screen-glow-on {
0%, from {
100% { opacity: 0;
transform: translateY(0);
}
50% {
transform: translateY(4px);
} }
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.animate-stagger-item, .screen-image,
.animate-scroll-bounce { .screen-glow {
animation: none; 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>
);
}