248 lines
8.5 KiB
JavaScript
248 lines
8.5 KiB
JavaScript
(() => {
|
||
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;
|
||
}
|
||
});
|
||
}
|
||
})();
|