Vectrum
Sign In
300 lines · 13.6 KB · TypeScript
import Link from "next/link";
import { Card, Surface } from "@unlocalhosted/metalui";
import { BRAND } from "@/components/brand";
import { HomeTerminal } from "@/components/home-terminal";
import { OrgInvitations } from "@/components/org-invitations";
import {
  ArrowRightIcon,
  BranchIcon,
  CodeIcon,
  CommitIcon,
  CompassIcon,
  IssueOpenedIcon,
  KeyIcon,
  OrganizationIcon,
  PencilIcon,
  PeopleIcon,
  PlusIcon,
  ShieldLockIcon,
  SparkleIcon,
} from "@/components/icons";
import { CardDescription, CardTitle } from "@/components/metal";
import { RepoFilterList } from "@/components/repo-filter-list";
import { Avatar, Blankslate, Box, ButtonLink, Counter, Label, RefChip, RepoAvatar } from "@/components/ui";
import { VerifyEmailNotice } from "@/components/verify-email-notice";
import { getViewer } from "@/lib/auth";
import { shortSha, timeAgo } from "@/lib/format";
import { recentActivity, withActivity } from "@/lib/listing";
import { MAIL_ENABLED } from "@/lib/mail";
import { listInvitationsFor, listOrgsOf } from "@/lib/orgs";
import { listReposForUser, searchRepos } from "@/lib/repos";
import { serverHost } from "@/lib/request-origin";
import { SIGNUPS_ENABLED } from "@/lib/signups";
import { sshCloneUrl } from "@/lib/ssh-config";
import type { User } from "@/lib/users";

export default async function Home() {
  const viewer = await getViewer();
  if (viewer) return <Dashboard viewer={viewer} />;
  return <Landing sshUrl={sshCloneUrl(await serverHost(), "you/project.git")} />;
}

const features = [
  { icon: CodeIcon, title: "Beautiful code browsing", text: "Syntax highlighting, rendered READMEs, full history and crisp diffs.", wide: true },
  { icon: PencilIcon, title: "Edit in the browser", text: "A real editor. Create, rename, upload and delete files in one commit." },
  { icon: KeyIcon, title: "SSH keys", text: "Push with git@ URLs, exactly the way you're used to." },
  { icon: ShieldLockIcon, title: "Scoped tokens", text: "Read-only or read & write, per repository, with expiry." },
  { icon: PeopleIcon, title: "Built for teams", text: "Accounts, collaborators, public and private repositories." },
  { icon: IssueOpenedIcon, title: "Issues", text: "Track bugs and ideas with markdown discussions.", wide: true },
  { icon: SparkleIcon, title: "Light & dark", text: "A calm interface that follows your system theme." },
];

/** A section heading on the landing page: tight display type in ink. */
const displayHeading = "font-semibold tracking-[-0.04em] text-ink";

function Landing({ sshUrl }: { sshUrl: string }) {
  return (
    <main className="flex-1">
      <section className="mx-auto max-w-6xl px-6 pt-20 pb-16 text-center md:pt-28">
        <div className="flex flex-wrap items-center justify-center gap-3">
          <Label tone="success">Self-hosted</Label>
          <span className="type-label engraved">Your code stays yours</span>
        </div>
        <h1 className={`mx-auto mt-7 max-w-4xl text-[44px] leading-[1.02] md:text-[72px] md:tracking-[-0.05em] ${displayHeading}`}>
          A calm, beautiful home <br className="hidden md:block" />
          for your code.
        </h1>
        <p className="mx-auto mt-6 max-w-xl text-[17px] leading-[26px] font-medium tracking-[-0.012em] text-ink2">
          {BRAND.name} hosts git repositories for your whole team. Push over SSH or HTTPS, edit in the browser, and keep private work private.
        </p>
        <div className="mt-9 flex flex-wrap justify-center gap-3">
          {SIGNUPS_ENABLED ? (
            <ButtonLink href="/signup" variant="primary" icon={<PlusIcon />}>
              Create Your Account
            </ButtonLink>
          ) : (
            <ButtonLink href="/login" variant="primary" icon={<ArrowRightIcon />}>
              Sign In
            </ButtonLink>
          )}
          <ButtonLink href="/explore" icon={<CompassIcon />}>
            Explore Repositories
          </ButtonLink>
        </div>

        <div className="mt-16">
          <HomeTerminal sshUrl={sshUrl} />
        </div>
      </section>

      <section className="mx-auto max-w-6xl px-6 py-20">
        <p className="text-center type-label engraved">Everything you need</p>
        <h2 className={`mt-3 text-center text-[34px] leading-[1.1] ${displayHeading}`}>Ship without the noise</h2>
        <p className="mx-auto mt-3 max-w-xl text-center text-[15px] leading-[22px] font-medium tracking-[-0.01em] text-ink2">
          The workflow you use every day, running on your own machine.
        </p>
        <div className="mt-12 grid gap-4 md:grid-cols-3">
          {features.map((f) => (
            <Card key={f.title} className={f.wide ? "md:col-span-2" : ""}>
              <span className="icon-tile mb-3 size-11 rounded-[14px]">
                <f.icon width={20} height={20} />
              </span>
              <CardTitle>{f.title}</CardTitle>
              <CardDescription className="max-w-md">{f.text}</CardDescription>
            </Card>
          ))}
        </div>
      </section>

      <section className="mx-auto max-w-6xl px-6">
        <Surface material="raise" radius="hero" className="px-8 py-14 text-center">
          <p className="type-label engraved">Get started</p>
          <h2 className={`mt-3 text-[30px] leading-[1.1] ${displayHeading}`}>Plant your first repository</h2>
          <p className="mt-2 type-lead text-ink2">It takes less than a minute.</p>
          <ButtonLink href={SIGNUPS_ENABLED ? "/signup" : "/login"} variant="primary" icon={<ArrowRightIcon />} className="mt-7">
            {SIGNUPS_ENABLED ? "Get Started for Free" : "Sign In"}
          </ButtonLink>
        </Surface>
      </section>
    </main>
  );
}

function greeting() {
  const h = new Date().getHours();
  return h < 5 ? "Good evening" : h < 12 ? "Good morning" : h < 18 ? "Good afternoon" : "Good evening";
}

/** A row in a list that lifts out of its plate on hover (MetalUI's list row). */
const liftRow = "rounded-row transition-row outline-none hover:recipe-row-list-hover focus-visible:recipe-row-list-hover focus-visible:focus-ring-flush";

async function Dashboard({ viewer }: { viewer: User }) {
  const [mine, recent, orgs, invitations] = await Promise.all([
    listReposForUser(viewer.id),
    searchRepos("", viewer.id, 30),
    listOrgsOf(viewer.id),
    listInvitationsFor(viewer.id),
  ]);
  const [activity, explore] = await Promise.all([
    recentActivity(mine),
    withActivity(recent.filter((r) => r.ownerId !== viewer.id && r.visibility === "public").slice(0, 5)),
  ]);

  return (
    <div className="mx-auto grid w-full max-w-[1400px] flex-1 gap-6 px-4 py-8 md:grid-cols-[280px_1fr] md:px-6 xl:grid-cols-[280px_1fr_300px]">
      <aside className="space-y-4">
        <Box className="p-4">
          <div className="mb-3 flex items-center justify-between gap-2">
            <h2 className="flex items-center gap-2 type-label engraved">
              Your repositories <Counter>{mine.length}</Counter>
            </h2>
            <ButtonLink href="/new" variant="invisible" icon={<PlusIcon />}>
              New
            </ButtonLink>
          </div>
          <RepoFilterList repos={mine.map((r) => ({ owner: r.owner, name: r.name, visibility: r.visibility, imageVersion: r.imageVersion }))} />
        </Box>
        <Box className="p-4">
          <div className="mb-2 flex items-center justify-between gap-2">
            <h2 className="flex items-center gap-2 type-label engraved">
              Your organizations <Counter>{orgs.length}</Counter>
            </h2>
            <ButtonLink href="/organizations/new" variant="invisible" icon={<PlusIcon />}>
              New
            </ButtonLink>
          </div>
          {orgs.length === 0 && <p className="type-body text-ink3">Share repositories with a team, and choose what each person may do.</p>}
          <ul className="-mx-2 flex flex-col gap-0.5">
            {orgs.map(({ org }) => (
              <li key={org.id}>
                <Link href={`/${org.username}`} className={`flex items-center gap-2.5 px-2 py-1.5 ${liftRow}`}>
                  <OrganizationIcon width={14} height={14} className="shrink-0 text-ink3" />
                  <span className="truncate type-ui text-ink">{org.username}</span>
                </Link>
              </li>
            ))}
          </ul>
        </Box>
      </aside>

      <main className="min-w-0">
        <div className="mb-6 flex items-center gap-4">
          <Avatar name={viewer.username} size={44} />
          <div className="min-w-0">
            <h1 className="text-[22px] leading-7 font-semibold tracking-[-0.025em] text-ink">
              {greeting()}, {viewer.name?.split(" ")[0] || viewer.username}
            </h1>
            <p className="mt-0.5 type-lead text-ink2">Here’s what’s been happening across your repositories.</p>
          </div>
        </div>

        {MAIL_ENABLED && !viewer.verified && (
          <div className="mb-6">
            <VerifyEmailNotice email={viewer.email} />
          </div>
        )}

        {invitations.length > 0 && (
          <div className="mb-6">
            <OrgInvitations invitations={invitations} />
          </div>
        )}

        {activity.length === 0 ? (
          <Box>
            <Blankslate
              icon={<SparkleIcon />}
              title={mine.length ? "No commits yet" : "Create your first repository"}
              action={
                <ButtonLink
                  href={mine.length ? `/${mine[0].owner}/${mine[0].name}` : "/new"}
                  variant="primary"
                  icon={mine.length ? <ArrowRightIcon /> : <PlusIcon />}
                >
                  {mine.length ? "Open Repository" : "Create Repository"}
                </ButtonLink>
              }
            >
              {mine.length ? "Push some code or add a file in the browser to see activity here." : "Repositories hold your project’s files and history."}
            </Blankslate>
          </Box>
        ) : (
          <Box className="p-1.5">
            <h2 className="flex items-center gap-2 px-3 pt-2.5 pb-2 type-label engraved">
              Recent activity <Counter>{activity.length}</Counter>
            </h2>
            <ol className="flex flex-col gap-0.5">
              {activity.map(({ repo, commit }) => (
                <li key={`${repo.id}-${commit.sha}`} className="flex gap-3 rounded-plate px-3 py-2.5 transition-row hover:recipe-row-list-hover">
                  <span className="icon-tile mt-0.5 size-8 shrink-0 rounded-full">
                    <CommitIcon width={15} height={15} />
                  </span>
                  <div className="min-w-0 flex-1">
                    <div className="flex flex-wrap items-baseline gap-x-1.5 type-body text-ink2">
                      <span className="font-medium text-ink">{commit.authorName}</span> pushed to
                      <Link href={`/${repo.owner}/${repo.name}`} className="font-medium text-ink hover:underline">
                        {repo.owner}/{repo.name}
                      </Link>
                      <span className="ml-auto type-readout text-ink3">{timeAgo(commit.date)}</span>
                    </div>
                    <Link href={`/${repo.owner}/${repo.name}/commit/${commit.sha}`} className="group mt-1.5 flex min-w-0 items-center gap-2.5">
                      <RefChip className="shrink-0">{shortSha(commit.sha)}</RefChip>
                      <span className="truncate type-ui text-ink group-hover:underline">{commit.subject}</span>
                    </Link>
                  </div>
                </li>
              ))}
            </ol>
          </Box>
        )}
      </main>

      <aside className="hidden space-y-4 xl:block">
        <Box className="p-4">
          <h2 className="mb-3 flex items-center gap-2 type-label engraved">
            <CompassIcon width={12} height={12} /> Discover
          </h2>
          {explore.length === 0 && <p className="type-body text-ink3">No public repositories from other users yet.</p>}
          <ul className="-mx-2 flex flex-col gap-0.5">
            {explore.map((r) => (
              <li key={r.id}>
                <Link href={`/${r.owner}/${r.name}`} className={`flex gap-3 p-2 ${liftRow}`}>
                  <RepoAvatar repo={r} size={32} />
                  <span className="min-w-0">
                    <span className="block truncate type-title text-ink">
                      <span className="font-normal text-ink3">{r.owner}/</span>
                      {r.name}
                    </span>
                    {r.description && <span className="mt-0.5 line-clamp-2 type-meta text-ink2">{r.description}</span>}
                  </span>
                </Link>
              </li>
            ))}
          </ul>
          <ButtonLink href="/explore" variant="invisible" icon={<ArrowRightIcon />} className="mt-2 -ml-2">
            Explore More
          </ButtonLink>
        </Box>
        <Box className="p-4">
          <div className="flex items-start gap-3">
            <span className="icon-tile size-9 shrink-0 rounded-[12px]">
              <BranchIcon />
            </span>
            <div className="min-w-0">
              <h2 className="type-title text-ink">Push access</h2>
              <p className="mt-0.5 type-meta text-ink2">Authenticate git with an SSH key or an API token.</p>
            </div>
          </div>
          <div className="rule-t -mx-2 mt-4 flex flex-col gap-0.5 pt-2">
            <Link href="/settings/keys" className={`flex items-center gap-2.5 px-2 py-1.5 type-ui text-ink ${liftRow}`}>
              <KeyIcon className="text-ink2" /> Manage SSH Keys
            </Link>
            <Link href="/settings/tokens" className={`flex items-center gap-2.5 px-2 py-1.5 type-ui text-ink ${liftRow}`}>
              <ShieldLockIcon className="text-ink2" /> Manage API Tokens
            </Link>
          </div>
        </Box>
      </aside>
    </div>
  );
}