Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions frontend/app/api/github-stars/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { NextResponse } from "next/server";

const REPO = "fujacob/cotabby";
const FALLBACK = 600;

export const revalidate = 86400;

export async function GET() {
const token = process.env.GITHUB_TOKEN;
const headers: HeadersInit = { Accept: "application/vnd.github+json" };
if (token) headers.Authorization = `token ${token}`;

let stars = FALLBACK;
try {
const res = await fetch(`https://api.github.com/repos/${REPO}`, {
headers,
next: { revalidate: 86400 },
});
if (res.ok) {
const data = (await res.json()) as { stargazers_count?: number };
if (typeof data.stargazers_count === "number") {
stars = data.stargazers_count;
}
}
} catch {
// fall through to fallback
}

const rounded = Math.max(FALLBACK, Math.floor(stars / 50) * 50);
return NextResponse.json(
{ stars: rounded },
{
headers: {
"Cache-Control": "public, s-maxage=86400, stale-while-revalidate=86400",
},
},
);
}
41 changes: 32 additions & 9 deletions frontend/app/components/alternating-feature-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { AnimatePresence, m, useReducedMotion } from "framer-motion";
import Image from "next/image";
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
import { FadeIn, WordReveal } from "./motion";
import { DemoGif } from "./demo-gif";

const VIDEO_ID = "p3TIgxQFQGE";

Expand Down Expand Up @@ -126,7 +127,7 @@ function VideoBlock({ className = "", label, start, end }: VideoBlockProps) {
<div
role="img"
aria-label={`${label} demo video`}
className={`relative aspect-video w-full overflow-hidden rounded-[1.35rem] border-2 border-line bg-surface shadow-[0_11.8px_0_var(--line)] ${className}`}
className={`relative aspect-video w-full overflow-hidden rounded-[1.35rem] border-2 border-line bg-surface shadow-[0_11.8px_0_var(--shadow-color)] ${className}`}
>
<SegmentPlayer start={start} end={end} />
<div className="absolute inset-0 z-10" />
Expand Down Expand Up @@ -168,7 +169,7 @@ function SectionHeadline({
return (
<div className={align === "right" ? "md:flex md:justify-end" : ""}>
<div className="inline-flex items-center gap-4">
<div className="relative h-12 w-12 shrink-0 overflow-hidden rounded-[0.85rem] border-2 border-line bg-surface-2 shadow-[0_5px_0_var(--line)] sm:h-14 sm:w-14">
<div className="relative h-12 w-12 shrink-0 overflow-hidden rounded-[0.85rem] border-2 border-line bg-surface-2 shadow-[0_5px_0_var(--shadow-color)] sm:h-14 sm:w-14">
<Image
src={icon}
alt=""
Expand All @@ -194,6 +195,8 @@ type FeatureRowProps = {
start?: number;
end?: number;
visual?: ReactNode;
/** Self-framed media (e.g. <DemoGif/>) rendered without the aspect-video box. */
media?: ReactNode;
};

function FeatureRow({
Expand All @@ -205,6 +208,7 @@ function FeatureRow({
start,
end,
visual,
media,
}: FeatureRowProps) {
const textFromLeft = layout === "text-left";

Expand Down Expand Up @@ -232,10 +236,12 @@ function FeatureRow({
transition={{ delay: 0.12 }}
className={textFromLeft ? "" : "md:order-1"}
>
{visual !== undefined ? (
{media !== undefined ? (
media
) : visual !== undefined ? (
<div
aria-label={`${label} demo`}
className="relative aspect-video w-full overflow-hidden rounded-[1.35rem] border-2 border-line bg-surface shadow-[0_11.8px_0_var(--line)]"
className="relative aspect-video w-full overflow-hidden rounded-[1.35rem] border-2 border-line bg-surface shadow-[0_11.8px_0_var(--shadow-color)]"
>
{visual}
</div>
Expand Down Expand Up @@ -358,7 +364,7 @@ function EmojiAutocompleteVisual() {
exit={{ opacity: 0, scale: 0.96, y: -4 }}
transition={{ duration: 0.18, ease: [0.22, 1, 0.36, 1] }}
style={{ transformOrigin: "top left" }}
className="absolute left-[5.2rem] top-[2.85rem] z-10 w-[13.5rem] overflow-hidden rounded-[0.85rem] border-2 border-line bg-surface-2 shadow-[0_5px_0_var(--line)] sm:left-[6rem] sm:top-[3.15rem] sm:w-[15rem]"
className="absolute left-[5.2rem] top-[2.85rem] z-10 w-[13.5rem] overflow-hidden rounded-[0.85rem] border-2 border-line bg-surface-2 shadow-[0_5px_0_var(--shadow-color)] sm:left-[6rem] sm:top-[3.15rem] sm:w-[15rem]"
>
<div className="flex items-center px-3 py-2 font-mono text-[0.78rem] tracking-tight">
<span className="text-subtle">:</span>
Expand Down Expand Up @@ -435,16 +441,33 @@ export function AlternatingFeatureSection() {
icon="/app-icons/slack.webp"
iconPad
label="slack"
start={33}
end={40}
media={
<DemoGif
src="/app-icons/slack.gif"
width={500}
height={182}
alt="Cotabby suggesting the rest of a Slack message and accepting it with Tab"
icon="/app-icons/slack.webp"
iconPad
label="Slack"
/>
}
/>
<FeatureRow
layout="text-right"
headline="write your messages faster"
icon="/app-icons/imessage.svg"
label="messages"
start={41}
end={50}
media={
<DemoGif
src="/app-icons/imessage.gif"
width={677}
height={233}
alt="Cotabby suggesting the rest of an iMessage and accepting it with Tab"
icon="/app-icons/imessage.svg"
label="iMessage"
/>
}
/>
<FeatureRow
layout="text-left"
Expand Down
53 changes: 44 additions & 9 deletions frontend/app/components/announcement-banner.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
"use client";

import Link from "next/link";
import { X } from "lucide-react";
import { useEffect, useState } from "react";
import { BANNER_DISMISS_KEY, RELEASE } from "../lib/site";

const RELEASE_DATE = new Date("2026-06-02T00:00:00Z");
const RELEASE_DATE = new Date(RELEASE.date);

function formatRelative(from: Date, to: Date) {
const seconds = Math.max(0, Math.floor((to.getTime() - from.getTime()) / 1000));
Expand All @@ -21,33 +23,66 @@ function formatRelative(from: Date, to: Date) {
}

export function AnnouncementBanner() {
const [relative, setRelative] = useState(() => formatRelative(RELEASE_DATE, new Date()));
const [relative, setRelative] = useState(() =>
formatRelative(RELEASE_DATE, new Date()),
);
const [dismissed, setDismissed] = useState(false);

useEffect(() => {
const update = () => setRelative(formatRelative(RELEASE_DATE, new Date()));
update();
const id = setInterval(update, 60_000);

// The init script may have already flagged this as dismissed (no-flash);
// mirror that into React state so the node is removed from the tree.
try {
if (localStorage.getItem(BANNER_DISMISS_KEY) === RELEASE.version) {
setDismissed(true);
}
} catch {
// ignore
}
return () => clearInterval(id);
}, []);

const dismiss = () => {
setDismissed(true);
try {
localStorage.setItem(BANNER_DISMISS_KEY, RELEASE.version);
} catch {
// ignore β€” still hidden for this session
}
const root = document.documentElement;
root.style.setProperty("--banner-height", "0px");
root.classList.add("tabby-banner-dismissed");
};

if (dismissed) return null;

return (
<div
id="announcement-banner"
style={{ color: "#ffffff" }}
className="fixed inset-x-0 top-0 z-[60] flex h-12 items-center justify-center bg-[var(--button-blue)] px-4 text-sm font-medium tracking-tight sm:text-base"
className="fixed inset-x-0 top-0 z-[60] flex h-12 items-center justify-center gap-3 bg-[var(--button-blue)] px-10 text-sm font-medium tracking-tight sm:text-base"
>
<span className="truncate">
v0.4.2-beta released {relative}. Send feedback at{" "}
{RELEASE.version} released {relative}. Send feedback at{" "}
<Link
href="/feedback"
style={{
color: "#ffffff",
textDecorationLine: "underline",
textUnderlineOffset: "2px",
}}
className="underline underline-offset-2 decoration-white/70 hover:decoration-white"
style={{ color: "#ffffff" }}
>
cotabby.app/feedback
</Link>
</span>
<button
type="button"
onClick={dismiss}
aria-label="Dismiss announcement"
className="absolute right-2 flex h-7 w-7 items-center justify-center rounded-lg text-white/80 transition-colors hover:bg-white/15 hover:text-white"
>
<X className="h-4 w-4" strokeWidth={2.5} />
</button>
</div>
);
}
88 changes: 88 additions & 0 deletions frontend/app/components/community-proof-section.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import Link from "next/link";
import { DISCORD_URL, GITHUB_URL } from "../lib/site";
import { GithubStarLabel } from "./github-star-label";
import { DiscordIcon, GithubIcon } from "./icons";
import { FadeIn, ScaleIn, WordReveal } from "./motion";

const githubActionClass =
"tabby-button tabby-button-primary inline-flex h-12 items-center justify-center gap-2 rounded-2xl px-6 text-sm font-bold tracking-tight sm:h-13 sm:text-base";

const discordActionClass =
"tabby-button tabby-button-secondary inline-flex h-12 items-center justify-center gap-2 rounded-2xl px-6 text-sm font-bold tracking-tight sm:h-13 sm:text-base";

function StatTile({
value,
label,
}: {
value: React.ReactNode;
label: string;
}) {
return (
<div className="rounded-[1.2rem] border-2 border-line bg-surface-3 px-5 py-6 text-center shadow-[0_5px_0_var(--shadow-color)]">
<div className="tabby-display text-[2.4rem] leading-none tracking-tight text-ink sm:text-[2.9rem]">
{value}
</div>
<div className="mt-2 text-xs font-semibold uppercase tracking-[0.14em] text-subtle">
{label}
</div>
</div>
);
}

export function CommunityProofSection() {
return (
<section className="mx-auto max-w-305">
<div className="overflow-hidden rounded-4xl border-2 border-line bg-surface-2 px-6 py-12 shadow-[0_11.8px_0_var(--shadow-color)] sm:px-10 sm:py-14">
<div className="flex flex-col items-center gap-3 text-center">
<WordReveal
as="h2"
text="free, and built in the open"
className="tabby-display text-[2.5rem] leading-[1.04] tracking-tight text-ink sm:text-[3.6rem]"
/>
<FadeIn delay={0.1}>
<p className="mx-auto max-w-2xl text-sm leading-relaxed tracking-tight text-muted sm:text-base">
No pitch decks or fake five-star quotes β€” just a public repo you can
read end to end, a license that keeps it free, and everything
running on your own machine.
</p>
</FadeIn>
</div>

<div className="mx-auto mt-10 grid max-w-3xl gap-4 sm:grid-cols-3">
<ScaleIn from={0.95}>
<StatTile value={<GithubStarLabel suffix="+" />} label="GitHub stars" />
</ScaleIn>
<ScaleIn from={0.95} delay={0.08}>
<StatTile value="AGPL-3.0" label="open-source license" />
</ScaleIn>
<ScaleIn from={0.95} delay={0.16}>
<StatTile value="100%" label="on-device inference" />
</ScaleIn>
</div>

<FadeIn delay={0.2}>
<div className="mt-10 flex flex-wrap items-center justify-center gap-3">
<Link
href={GITHUB_URL}
target="_blank"
rel="noopener noreferrer"
className={githubActionClass}
>
<GithubIcon className="h-5 w-5" />
Star on GitHub
</Link>
<Link
href={DISCORD_URL}
target="_blank"
rel="noopener noreferrer"
className={discordActionClass}
>
<DiscordIcon className="h-5 w-5" />
Join the Discord
</Link>
</div>
</FadeIn>
</div>
</section>
);
}
Loading