8.4 KiB · text History 376a05c
(() => {
  const isTypingTarget = (el) => {
    if (!el) return false;
    const tag = el.tagName;
    if (tag === "TEXTAREA") return true;
    if (tag === "INPUT") {
      const t = el.type;
      return t !== "file" && t !== "checkbox" && t !== "radio" && t !== "button" && t !== "submit";
    }
    return !!el.isContentEditable;
  };

  const extFor = (type) => {
    if (!type) return "bin";
    const slash = type.indexOf("/");
    return slash >= 0 ? type.slice(slash + 1).split("+")[0] : "bin";
  };

  const pastedName = (blob) => {
    if (blob.name && blob.name !== "image.png") return blob;
    return new File([blob], `pasted-${Date.now()}.${extFor(blob.type)}`, { type: blob.type });
  };

  // Toast — created on demand, auto-removes after a few seconds.
  const showToast = (msg, opts = {}) => {
    let host = document.getElementById("dms-toast-host");
    if (!host) {
      host = document.createElement("div");
      host.id = "dms-toast-host";
      host.className = "toast toast-end z-50";
      document.body.appendChild(host);
    }
    const alert = document.createElement("div");
    const kind = opts.kind || "info";
    alert.className = `alert alert-${kind} shadow-lg`;
    if (opts.href) {
      const a = document.createElement("a");
      a.href = opts.href;
      a.className = "underline";
      a.textContent = msg;
      alert.appendChild(a);
    } else {
      const span = document.createElement("span");
      span.textContent = msg;
      alert.appendChild(span);
    }
    host.appendChild(alert);
    const ttl = opts.ttl ?? 4000;
    if (ttl > 0) setTimeout(() => alert.remove(), ttl);
    return alert;
  };

  // Path 1: a quick-upload form is on this page — attach the pasted file to it.
  const attachToQuickForm = (file) => {
    const fileInput = document.getElementById("quick-upload-file");
    const preview = document.getElementById("quick-upload-preview");
    if (!fileInput) return false;
    const dt = new DataTransfer();
    dt.items.add(file);
    fileInput.files = dt.files;
    if (preview) {
      const img = preview.querySelector("img");
      const name = preview.querySelector("[data-name]");
      if (img) {
        if (img.src) URL.revokeObjectURL(img.src);
        img.src = URL.createObjectURL(file);
      }
      if (name) name.textContent = `${file.name} · ${(file.size / 1024).toFixed(1)} KB`;
      preview.classList.remove("hidden");
      preview.classList.add("flex");
    }
    fileInput.scrollIntoView({ behavior: "smooth", block: "center" });
    return true;
  };

  // Path 2: anywhere else on the site — POST it directly and surface the result.
  const uploadDirect = async (file) => {
    const limitInput = document.querySelector("input[type=file][data-max-bytes]");
    if (limitInput && overLimit(file, limitInput)) return;
    const fd = new FormData();
    fd.append("file", file, file.name);
    const status = showToast(`Uploading ${file.name}…`, { kind: "info", ttl: 0 });
    try {
      const res = await fetch("/uploads", { method: "POST", body: fd, credentials: "same-origin" });
      status.remove();
      if (res.redirected && res.url.includes("/login")) {
        showToast("Sign in to upload.", { kind: "warning" });
        return;
      }
      if (!res.ok) {
        showToast(`Upload failed (${res.status}).`, { kind: "error" });
        return;
      }
      const href = res.redirected ? res.url : "/uploads";
      showToast(`Uploaded ${file.name} — view`, { kind: "success", href });
    } catch (e) {
      status.remove();
      showToast(`Upload error: ${e.message || e}`, { kind: "error" });
    }
  };

  // Fetch a pasted link server-side. Going through the server keeps the
  // original bytes (animated GIFs, videos) that a clipboard image-paste would
  // otherwise flatten into a single-frame PNG.
  const uploadUrl = async (url) => {
    const status = showToast(`Fetching ${url}…`, { kind: "info", ttl: 0 });
    try {
      const res = await fetch("/uploads/url", {
        method: "POST",
        headers: { "Content-Type": "application/x-www-form-urlencoded" },
        body: new URLSearchParams({ url }),
        credentials: "same-origin",
      });
      status.remove();
      if (res.redirected && res.url.includes("/login")) {
        showToast("Sign in to upload.", { kind: "warning" });
        return;
      }
      if (!res.ok) {
        showToast(`Fetch failed (${res.status}).`, { kind: "error" });
        return;
      }
      const href = res.redirected ? res.url : "/uploads";
      showToast("Fetched link — view", { kind: "success", href });
    } catch (e) {
      status.remove();
      showToast(`Fetch error: ${e.message || e}`, { kind: "error" });
    }
  };

  const looksLikeUrl = (text) => /^https?:\/\/\S+$/i.test((text || "").trim());

  document.addEventListener("paste", (e) => {
    if (!document.body.dataset.authed) return;
    if (isTypingTarget(e.target)) return;
    const cd = e.clipboardData;
    const items = cd && cd.items;
    if (!items) return;
    for (const it of items) {
      if (it.kind !== "file") continue;
      const blob = it.getAsFile();
      if (!blob || !blob.type.startsWith("image/")) continue;
      const file = pastedName(blob);
      e.preventDefault();
      if (!attachToQuickForm(file)) uploadDirect(file);
      return;
    }
    // No file on the clipboard — fall back to a pasted link.
    const text = cd.getData && cd.getData("text/plain");
    if (looksLikeUrl(text)) {
      e.preventDefault();
      uploadUrl(text.trim());
    }
  });

  // Video and GIF tiles point at files a worker produces — the animated
  // `preview`, then the `poster` frame. Either job may not have run yet (or
  // may have failed on a corrupt file), so step down to `data-fallback-src`
  // first, and only then swap a broken image for a label rather than leaving
  // a broken-image icon.
  document.querySelectorAll("img[data-fallback]").forEach((img) => {
    const onError = () => {
      const next = img.dataset.fallbackSrc;
      if (next) {
        delete img.dataset.fallbackSrc;
        img.src = next;
        return;
      }
      img.removeEventListener("error", onError);
      const box = document.createElement("div");
      box.className = img.className + " flex items-center justify-center bg-base-300 text-xs opacity-70";
      box.textContent = img.dataset.fallback || "no preview";
      img.replaceWith(box);
    };
    img.addEventListener("error", onError);
  });

  const humanBytes = (n) => {
    const mib = 1024 * 1024;
    if (n >= mib) return `${(n / mib).toFixed(1)} MiB`;
    if (n >= 1024) return `${(n / 1024).toFixed(1)} KiB`;
    return `${n} B`;
  };

  // Block oversize files in the browser rather than discovering the limit
  // mid-upload. The server answers as soon as it has read the headers, which
  // leaves the rest of the body in flight and reads to a proxy as a 502 — so
  // the friendly "too large" page never reaches the user. Checking here means
  // the bytes are never sent at all.
  const overLimit = (file, input) => {
    const max = parseInt(input.dataset.maxBytes || "0", 10);
    if (!max || !file || file.size <= max) return false;
    showToast(
      `${file.name} is ${humanBytes(file.size)} — the limit is ${humanBytes(max)}.`,
      { kind: "error", ttl: 8000 }
    );
    return true;
  };

  document.querySelectorAll("input[type=file][data-max-bytes]").forEach((input) => {
    const form = input.closest("form");
    if (!form) return;
    form.addEventListener("submit", (e) => {
      const file = input.files && input.files[0];
      if (overLimit(file, input)) e.preventDefault();
    });
  });

  // When the quick-upload form is on the page, also preview manual file picks.
  const quickFile = document.getElementById("quick-upload-file");
  const quickPreview = document.getElementById("quick-upload-preview");
  if (quickFile && quickPreview) {
    const img = quickPreview.querySelector("img");
    const name = quickPreview.querySelector("[data-name]");
    quickFile.addEventListener("change", () => {
      const f = quickFile.files && quickFile.files[0];
      if (!f || !f.type.startsWith("image/")) {
        quickPreview.classList.add("hidden");
        quickPreview.classList.remove("flex");
        return;
      }
      if (img) {
        if (img.src) URL.revokeObjectURL(img.src);
        img.src = URL.createObjectURL(f);
      }
      if (name) name.textContent = `${f.name} · ${(f.size / 1024).toFixed(1)} KB`;
      quickPreview.classList.remove("hidden");
      quickPreview.classList.add("flex");
    });
  }
})();