2026-07-10 23:03:25 +09:00

248 lines
8.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(() => {
document.querySelectorAll("[data-auto-submit='true']").forEach((element) => {
element.addEventListener("change", () => {
if (element.form) element.form.submit();
});
});
})();
(() => {
const usage = document.getElementById("Usage");
const redirect = document.getElementById("RedirectUris");
const clientType = document.getElementById("ClientType");
if (!usage || !redirect || !clientType) return;
const syncRedirectInputState = () => {
const usageValue = usage.value;
const needsRedirect = usageValue === "web_login" || usageValue === "webhook_outbound";
const requiresConfidential = usageValue === "tenant_api"
|| usageValue === "send_api"
|| usageValue === "platform_service"
|| usageValue === "file_api";
redirect.disabled = !needsRedirect;
if (!needsRedirect) redirect.value = "";
const publicOption = clientType.querySelector('option[value="public"]');
if (publicOption) publicOption.disabled = requiresConfidential;
if (requiresConfidential) clientType.value = "confidential";
};
usage.addEventListener("change", syncRedirectInputState);
syncRedirectInputState();
})();
(() => {
const forms = document.querySelectorAll("[data-avatar-upload]");
if (!forms.length) return;
const targetSize = 256;
const maxOutputBytes = 512 * 1024;
const readFileAsImage = (file) => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error("Invalid image."));
image.src = reader.result;
};
reader.onerror = () => reject(new Error("Invalid image."));
reader.readAsDataURL(file);
});
const canvasToBlob = (canvas, type, quality) => new Promise((resolve) => {
canvas.toBlob(resolve, type, quality);
});
const blobToDataUrl = (blob) => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
const submitForm = (form) => {
HTMLFormElement.prototype.submit.call(form);
};
forms.forEach((form) => {
const fileInput = form.querySelector("[data-avatar-file]");
const dataUrlInput = form.querySelector("[data-avatar-data-url]");
const contentTypeInput = form.querySelector("[data-avatar-content-type]");
const widthInput = form.querySelector("[data-avatar-width]");
const heightInput = form.querySelector("[data-avatar-height]");
if (!fileInput || !dataUrlInput || !contentTypeInput || !widthInput || !heightInput) return;
fileInput.addEventListener("change", async () => {
const file = fileInput.files && fileInput.files[0];
if (!file) return;
try {
const image = await readFileAsImage(file);
openCropDialog(form, image, () => {
fileInput.value = "";
}, async (cropState) => {
const canvas = document.createElement("canvas");
canvas.width = targetSize;
canvas.height = targetSize;
const context = canvas.getContext("2d");
if (!context) throw new Error("Canvas is not available.");
context.drawImage(
image,
(targetSize - cropState.renderWidth) / 2 + cropState.offsetX,
(targetSize - cropState.renderHeight) / 2 + cropState.offsetY,
cropState.renderWidth,
cropState.renderHeight);
let blob = await canvasToBlob(canvas, "image/webp", 0.82);
if (!blob) blob = await canvasToBlob(canvas, "image/png");
if (!blob || blob.size > maxOutputBytes) {
throw new Error("Image output is too large.");
}
dataUrlInput.value = await blobToDataUrl(blob);
contentTypeInput.value = blob.type || "image/png";
widthInput.value = String(targetSize);
heightInput.value = String(targetSize);
submitForm(form);
});
} catch {
fileInput.value = "";
}
});
});
function openCropDialog(form, image, onCancel, onSave) {
const strings = {
title: form.dataset.cropTitle || "Adjust profile image",
zoom: form.dataset.cropZoom || "Zoom",
save: form.dataset.cropSave || "Save",
cancel: form.dataset.cropCancel || "Cancel",
error: form.dataset.cropError || "Unable to process this image."
};
const dialog = document.createElement("div");
dialog.className = "avatar-crop-modal";
dialog.innerHTML = `
<div class="avatar-crop-backdrop" data-avatar-crop-cancel></div>
<div class="avatar-crop-panel" role="dialog" aria-modal="true" aria-labelledby="avatar-crop-title">
<div class="avatar-crop-heading">
<h2 id="avatar-crop-title"></h2>
<button type="button" class="avatar-crop-close" data-avatar-crop-cancel aria-label="${strings.cancel}">×</button>
</div>
<div class="avatar-crop-stage">
<img alt="" draggable="false" />
</div>
<label class="avatar-crop-slider">
<span></span>
<input type="range" min="1" max="3" step="0.01" value="1" />
</label>
<p class="avatar-crop-error" hidden></p>
<div class="avatar-crop-actions">
<button type="button" class="profile-pill-button" data-avatar-crop-cancel></button>
<button type="button" class="profile-pill-button" data-avatar-crop-save></button>
</div>
</div>`;
dialog.querySelector("#avatar-crop-title").textContent = strings.title;
dialog.querySelector(".avatar-crop-slider span").textContent = strings.zoom;
dialog.querySelector("[data-avatar-crop-cancel].profile-pill-button").textContent = strings.cancel;
dialog.querySelector("[data-avatar-crop-save]").textContent = strings.save;
const preview = dialog.querySelector(".avatar-crop-stage img");
const stage = dialog.querySelector(".avatar-crop-stage");
const zoomInput = dialog.querySelector(".avatar-crop-slider input");
const error = dialog.querySelector(".avatar-crop-error");
const state = {
zoom: 1,
offsetX: 0,
offsetY: 0,
renderWidth: targetSize,
renderHeight: targetSize
};
const baseScale = targetSize / Math.min(image.naturalWidth, image.naturalHeight);
let dragStart = null;
const clamp = () => {
state.renderWidth = image.naturalWidth * baseScale * state.zoom;
state.renderHeight = image.naturalHeight * baseScale * state.zoom;
const maxX = Math.max(0, (state.renderWidth - targetSize) / 2);
const maxY = Math.max(0, (state.renderHeight - targetSize) / 2);
state.offsetX = Math.max(-maxX, Math.min(maxX, state.offsetX));
state.offsetY = Math.max(-maxY, Math.min(maxY, state.offsetY));
};
const render = () => {
clamp();
preview.style.width = `${state.renderWidth}px`;
preview.style.height = `${state.renderHeight}px`;
preview.style.transform = `translate(-50%, -50%) translate(${state.offsetX}px, ${state.offsetY}px)`;
};
const close = () => {
dialog.remove();
};
preview.src = image.src;
document.body.appendChild(dialog);
render();
dialog.querySelectorAll("[data-avatar-crop-cancel]").forEach((button) => {
button.addEventListener("click", () => {
close();
onCancel();
});
});
zoomInput.addEventListener("input", () => {
const previousZoom = state.zoom;
state.zoom = Number(zoomInput.value);
if (previousZoom > 0) {
state.offsetX *= state.zoom / previousZoom;
state.offsetY *= state.zoom / previousZoom;
}
render();
});
stage.addEventListener("pointerdown", (event) => {
stage.setPointerCapture(event.pointerId);
dragStart = {
x: event.clientX,
y: event.clientY,
offsetX: state.offsetX,
offsetY: state.offsetY
};
});
stage.addEventListener("pointermove", (event) => {
if (!dragStart) return;
state.offsetX = dragStart.offsetX + event.clientX - dragStart.x;
state.offsetY = dragStart.offsetY + event.clientY - dragStart.y;
render();
});
stage.addEventListener("pointerup", () => {
dragStart = null;
});
stage.addEventListener("pointercancel", () => {
dragStart = null;
});
dialog.querySelector("[data-avatar-crop-save]").addEventListener("click", async () => {
error.hidden = true;
try {
await onSave({ ...state });
} catch {
error.textContent = strings.error;
error.hidden = false;
}
});
}
})();