image-url-upload.tsx 6.59 KB
import React, { useRef, useState } from "react";
import { Plus, X } from "lucide-react";
import { toast } from "sonner";
import { cn } from "./utils";
import { PICTURE_UPLOAD_MAX_BYTES, resolvePictureUrlForDisplay, uploadImageFile } from "../../services/imageUploadService";

export type ImageUrlUploadProps = {
  value: string;
  onChange: (url: string) => void;
  disabled?: boolean;
  /** 辅助说明,显示在方框下方 */
  hint?: string;
  /** 空状态主文案(默认无,仅加号;需要时传入如 "Click to upload") */
  emptyLabel?: string;
  accept?: string;
  /** 默认 5MB,与平台 picture 上传接口一致 */
  maxSizeMb?: number;
  className?: string;
  /** 上传区域宽度(tailwind),默认 max-w-[200px] */
  boxClassName?: string;
  /** 固定上传区边长(px);优先于仅依赖 Tailwind 的 boxClassName,避免预构建 CSS 缺类名时撑满整行 */
  boxSizePx?: number;
  /** 传给后端的 multipart `subDir`(如 category、product) */
  uploadSubDir?: string;
  /** 明确单图:隐藏多选、提示文案 */
  oneImageOnly?: boolean;
};

export function ImageUrlUpload({
  value,
  onChange,
  disabled,
  hint,
  emptyLabel = "",
  accept = "image/jpeg,image/png,image/webp,image/gif",
  maxSizeMb = PICTURE_UPLOAD_MAX_BYTES / (1024 * 1024),
  className,
  boxClassName,
  boxSizePx,
  uploadSubDir,
  oneImageOnly,
}: ImageUrlUploadProps) {
  const inputRef = useRef<HTMLInputElement>(null);
  const [uploading, setUploading] = useState(false);

  const onFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    e.target.value = "";
    if (!file) return;
    if (!file.type.startsWith("image/")) {
      toast.error("Please select an image file.");
      return;
    }
    if (file.size > maxSizeMb * 1024 * 1024) {
      toast.error(`Image must be ${maxSizeMb} MB or smaller.`);
      return;
    }

    setUploading(true);
    try {
      const url = await uploadImageFile(file, { subDir: uploadSubDir });
      onChange(url);
      toast.success("Image uploaded.");
    } catch (err: unknown) {
      const msg = err instanceof Error ? err.message : String(err);
      toast.error("Upload failed", { description: msg || undefined });
    } finally {
      setUploading(false);
    }
  };

  const busy = disabled || uploading;

  const openPicker = () => {
    if (!busy) inputRef.current?.click();
  };

  const fixedSize = typeof boxSizePx === "number" && boxSizePx > 0 ? Math.round(boxSizePx) : 0;
  const hasCustomBox = fixedSize > 0 || Boolean(boxClassName?.trim());
  const boxSizeStyle: React.CSSProperties | undefined =
    fixedSize > 0
      ? {
          width: fixedSize,
          height: fixedSize,
          minWidth: fixedSize,
          minHeight: fixedSize,
          maxWidth: fixedSize,
          maxHeight: fixedSize,
          boxSizing: "border-box",
        }
      : undefined;
  const boxShell = hasCustomBox
    ? "box-border shrink-0 rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
    : "w-full max-w-[200px] aspect-square rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2";

  return (
    <div className={cn("space-y-2", hasCustomBox && "w-fit max-w-full", className)}>
      <input
        ref={inputRef}
        type="file"
        accept={accept}
        className="sr-only"
        disabled={busy}
        multiple={false}
        onChange={onFileChange}
      />

      {!value ? (
        <button
          type="button"
          disabled={busy}
          onClick={openPicker}
          aria-label={emptyLabel || "Upload image"}
          style={boxSizeStyle}
          className={cn(
            boxShell,
            hasCustomBox ? boxClassName : null,
            "flex border-2 border-dashed border-gray-300 bg-gray-50/80 text-gray-400",
            emptyLabel && !uploading
              ? "flex-col items-center justify-center gap-2"
              : "items-center justify-center",
            "hover:border-gray-400 hover:bg-gray-100/90 hover:text-gray-500",
            "disabled:pointer-events-none disabled:opacity-50",
          )}
        >
          {uploading ? (
            <span className="px-3 text-center text-sm font-normal text-gray-500">Uploading…</span>
          ) : (
            <>
              <Plus
                className={cn(
                  "shrink-0 stroke-[1.25]",
                  fixedSize > 0 && fixedSize <= 120 ? "h-8 w-8" : "h-10 w-10",
                )}
                aria-hidden
              />
              {emptyLabel ? (
                <span className="px-3 text-center text-sm font-normal leading-tight text-gray-400">
                  {emptyLabel}
                </span>
              ) : null}
            </>
          )}
        </button>
      ) : (
        <div
          style={boxSizeStyle}
          className={cn(
            "group relative overflow-hidden border-2 border-dashed border-gray-300 bg-gray-50/80",
            boxShell,
            hasCustomBox ? boxClassName : null,
          )}
        >
          <button
            type="button"
            disabled={busy}
            onClick={openPicker}
            className="relative h-full w-full p-0"
            aria-label="Replace image"
          >
            <img
              src={resolvePictureUrlForDisplay(value)}
              alt=""
              className="h-full w-full object-contain"
              onError={(ev) => {
                (ev.target as HTMLImageElement).style.opacity = "0.2";
              }}
            />
            <span className="absolute inset-0 flex items-center justify-center bg-black/0 text-sm font-medium text-white opacity-0 transition group-hover:bg-black/45 group-hover:opacity-100">
              Click to replace
            </span>
          </button>
          <button
            type="button"
            disabled={busy}
            onClick={(e) => {
              e.stopPropagation();
              onChange("");
            }}
            className="absolute right-1.5 top-1.5 flex h-7 w-7 items-center justify-center rounded-full bg-white/95 text-gray-600 shadow-sm ring-1 ring-gray-200 transition hover:bg-white hover:text-gray-900 disabled:opacity-50"
            aria-label="Remove image"
          >
            <X className="h-4 w-4" />
          </button>
        </div>
      )}

      {oneImageOnly ? (
        <p className="text-xs text-muted-foreground">One image only. Replace or clear to change.</p>
      ) : null}
      {hint ? <p className="text-xs text-muted-foreground">{hint}</p> : null}
    </div>
  );
}