Vectrum
Sign In
85 lines · 3.2 KB · TypeScript
"use client";

import { useActionState, useEffect, useState, type ReactNode } from "react";
import type { FormState } from "@/app/actions/types";
import { UndoIcon, UploadIcon } from "./icons";
import { PlateBody } from "./settings-plate";
import { Alert, Button, buttonClass } from "./ui";

/**
 * Uploads a picture (a repository's image, an organization's avatar) with a preview of the chosen
 * file before it's saved, and offers to remove the one there is.
 */
export function ImageUploadForm({
  action,
  resetAction,
  hasImage,
  noun,
  resetLabel,
  description,
  renderImage,
}: {
  action: (prev: FormState, formData: FormData) => Promise<FormState>;
  resetAction: () => Promise<void>;
  hasImage: boolean;
  /** "Image" or "Avatar", for the buttons. */
  noun: string;
  resetLabel: string;
  description: ReactNode;
  /** The picture as it's shown, or with `src` (the chosen file) instead. */
  renderImage: (src?: string) => ReactNode;
}) {
  const [preview, setPreview] = useState<string | null>(null);
  const [state, formAction, pending] = useActionState(async (prev: FormState, formData: FormData) => {
    const result = await action(prev, formData);
    if (result?.success) setPreview(null);
    return result;
  }, undefined);
  useEffect(() => () => {
    if (preview) URL.revokeObjectURL(preview);
  }, [preview]);

  return (
    <PlateBody>
      <div className="flex flex-wrap items-start gap-5">
        {renderImage(preview ?? undefined)}
        <div className="flex min-w-60 flex-1 flex-col gap-3">
          <p className="type-body text-ink2">{description} PNG, JPEG, GIF or WebP, up to 1&nbsp;MB.</p>
          <div className="flex flex-wrap items-center gap-2">
            <form action={formAction} className="flex items-center gap-2">
              {/* The file input stays a real input; its label is drawn as MetalUI's standard cap. */}
              <label className={buttonClass("default", "has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-green-deep has-focus-visible:outline-solid")}>
                <UploadIcon /> {hasImage ? `Replace ${noun}` : `Upload ${noun}`}
                <input
                  type="file"
                  name="image"
                  accept="image/png,image/jpeg,image/gif,image/webp"
                  required
                  className="sr-only"
                  onChange={(e) => {
                    const file = e.target.files?.[0];
                    setPreview(file ? URL.createObjectURL(file) : null);
                  }}
                />
              </label>
              {preview && (
                <Button variant="primary" disabled={pending}>
                  {pending ? "Saving…" : `Save ${noun}`}
                </Button>
              )}
            </form>
            {hasImage && !preview && (
              <form action={resetAction}>
                <Button variant="invisible" icon={<UndoIcon />}>
                  {resetLabel}
                </Button>
              </form>
            )}
          </div>
          {state?.error && <Alert>{state.error}</Alert>}
          {state?.success && !preview && <Alert kind="success">{state.success}</Alert>}
        </div>
      </div>
    </PlateBody>
  );
}