Vectrum
Sign In
138 lines · 6.4 KB · TypeScript
"use client";

import { useActionState, useOptimistic, useState, useTransition } from "react";
import { Button, Checkbox, Field, Popover, Rule, Select, Toggle, useToast } from "@unlocalhosted/metalui";
import { createListAction, setLikedAction, setSavedAction } from "@/app/actions/lists";
import type { FormState } from "@/app/actions/types";
import { BookmarkIcon, ChevronDownIcon, HeartIcon, LockIcon, PlusIcon } from "./icons";
import { ButtonLink } from "./ui";

/** The repository's Like key: latched (a filled heart) while the viewer likes it. Signed out, it leads to signing in. */
export function LikeButton({ owner, name, liked, signedIn }: { owner: string; name: string; liked: boolean; signedIn: boolean }) {
  const toast = useToast();
  const [pressed, setPressed] = useOptimistic(liked);
  const [, startTransition] = useTransition();
  if (!signedIn) {
    return (
      <ButtonLink href={`/login?next=${encodeURIComponent(`/${owner}/${name}`)}`} icon={<HeartIcon />}>
        Like
      </ButtonLink>
    );
  }
  return (
    <Toggle
      lamp={false}
      pressed={pressed}
      aria-label={pressed ? "Unlike" : "Like"}
      onPressedChange={(next) =>
        startTransition(async () => {
          setPressed(next);
          const result = await setLikedAction(owner, name, next);
          if (result?.error) toast.show({ title: result.error, tone: "error" });
        })
      }
    >
      <HeartIcon fill={pressed ? "currentColor" : "none"} className={pressed ? "text-red" : ""} />
      {pressed ? "Liked" : "Like"}
    </Toggle>
  );
}

export type SaveTarget = { id: string; owner: string; name: string; visibility: "public" | "private"; saved: boolean };

/**
 * The repository's Save key: a popover with the lists the viewer may change (theirs and their
 * organizations'), ticked where the repository is in them, and a field to make a new list with it.
 */
export function SaveButton({ repoId, lists, owners, viewer }: { repoId: string; lists: SaveTarget[]; owners: string[]; viewer: string }) {
  const toast = useToast();
  const [shown, setSaved] = useOptimistic(lists, (state, change: { id: string; saved: boolean }) =>
    state.map((l) => (l.id === change.id ? { ...l, saved: change.saved } : l)),
  );
  const [, startTransition] = useTransition();
  const anySaved = shown.some((l) => l.saved);
  const byOwner = owners.map((owner) => ({ owner, lists: shown.filter((l) => l.owner === owner) })).filter((g) => g.lists.length > 0);

  const toggle = (list: SaveTarget, saved: boolean) =>
    startTransition(async () => {
      setSaved({ id: list.id, saved });
      const result = await setSavedAction(list.id, repoId, saved);
      if (result?.error) toast.show({ title: result.error, tone: "error" });
    });

  return (
    <Popover>
      <Popover.Trigger>
        <Button icon={<BookmarkIcon fill={anySaved ? "currentColor" : "none"} />}>
          {anySaved ? "Saved" : "Save"}
          <span aria-hidden className="-mr-1 inline-grid opacity-70">
            <ChevronDownIcon width={12} height={12} />
          </span>
        </Button>
      </Popover.Trigger>
      <Popover.Content side="bottom" align="end" className="w-[300px]">
        <Popover.Title className="flex items-center gap-2">
          <BookmarkIcon width={14} height={14} className="text-ink2" /> Save to a list
        </Popover.Title>
        <Popover.Body className="flex flex-col gap-3">
          {byOwner.length === 0 && <p className="type-body text-ink3">Lists gather repositories you want to find again or share. Make your first one below.</p>}
          {byOwner.map((group) => (
            <div key={group.owner} className="flex flex-col gap-0.5">
              {owners.length > 1 && <p className="px-1 pb-1 type-label engraved">{group.owner === viewer ? "Your lists" : group.owner}</p>}
              <ul className="flex max-h-56 flex-col gap-0.5 overflow-y-auto">
                {group.lists.map((list) => (
                  <li key={list.id}>
                    <label className="flex cursor-pointer items-center gap-2.5 rounded-[10px] px-1.5 py-1.5 transition-row hover:recipe-row-list-hover">
                      <Checkbox size="row" checked={list.saved} onCheckedChange={(checked) => toggle(list, checked)} />
                      <span className="min-w-0 flex-1 truncate type-ui text-ink">{list.name}</span>
                      {list.visibility === "private" && <LockIcon width={12} height={12} className="shrink-0 text-ink3" aria-label="Private" />}
                    </label>
                  </li>
                ))}
              </ul>
            </div>
          ))}
          <Rule />
          <NewListForm repoId={repoId} owners={owners} viewer={viewer} />
        </Popover.Body>
      </Popover.Content>
    </Popover>
  );
}

/** Makes a list with the repository in it, without leaving the page. */
function NewListForm({ repoId, owners, viewer }: { repoId: string; owners: string[]; viewer: string }) {
  const toast = useToast();
  const [created, setCreated] = useState(0);
  const [, action, pending] = useActionState(async (prev: FormState, formData: FormData) => {
    const result = await createListAction(prev, formData);
    if (result?.error) toast.show({ title: result.error, tone: "error" });
    else {
      if (result?.success) toast.show({ title: result.success, tone: "success" });
      setCreated((n) => n + 1);
    }
    return result;
  }, undefined);
  return (
    // A new key empties the fields once the list is made.
    <form key={created} action={action} className="flex flex-col gap-2.5">
      <input type="hidden" name="repo" value={repoId} />
      <Field size="regular">
        <Field.Input name="name" required maxLength={60} placeholder="New list, e.g. Tools I use" aria-label="New list name" />
      </Field>
      <div className="flex flex-wrap items-center gap-2">
        {owners.length > 1 ? (
          <Select name="owner" aria-label="Owner of the list" size="compact" defaultValue={viewer} options={owners.map((o) => ({ value: o, label: o }))} className="min-w-24" />
        ) : (
          <input type="hidden" name="owner" value={viewer} />
        )}
        <label className="flex cursor-pointer items-center gap-2 type-ui text-ink2">
          <Checkbox name="visibility" value="private" /> Private
        </label>
        <Button type="submit" size="compact" icon={<PlusIcon />} disabled={pending} className="ml-auto">
          {pending ? "Creating…" : "Create"}
        </Button>
      </div>
    </form>
  );
}