stackchain-dashboard/frontend/issue-attachment.js
timmy de596f397f
All checks were successful
CI / lint (pull_request) Successful in 56s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
fix: make screenshot uploads retry-safe (#471)
2026-08-10 09:54:56 +00:00

156 lines
5.3 KiB
JavaScript

(function(root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else root.issueAttachment = api;
})(typeof self !== 'undefined' ? self : this, function() {
'use strict';
const MAX_BYTES = 2 * 1024 * 1024;
const IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
function create(options) {
const readDataUrl = options.readDataUrl;
const upload = options.upload;
const createOperationId = options.createOperationId || (() => {
if (typeof globalThis !== 'undefined' && globalThis.crypto?.randomUUID) {
return globalThis.crypto.randomUUID();
}
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
});
let selected = null;
let confirmed = null;
let serialized = null;
let operationId = null;
function select(file) {
if (!file || !IMAGE_TYPES.has(file.type)) {
throw new Error('Choose a PNG, JPEG, or WebP screenshot.');
}
if (!Number.isFinite(file.size) || file.size <= 0 || file.size > MAX_BYTES) {
throw new Error('Choose a screenshot that is 2 MB or smaller.');
}
selected = file;
confirmed = null;
serialized = null;
operationId = createOperationId();
return state();
}
function clear() {
selected = null;
confirmed = null;
serialized = null;
operationId = null;
}
function restore(value) {
const contentType = String(value?.contentType || '');
const filename = String(value?.filename || '');
const data = String(value?.data || '');
const padding = (data.match(/=*$/) || [''])[0].length;
const size = Math.max(1, Math.floor(data.length * 3 / 4) - padding);
select({ name: filename, type: contentType, size });
serialized = { filename, contentType, data };
return state();
}
function state() {
return selected ? {
name: selected.name,
size: selected.size,
uploaded: Boolean(confirmed),
} : null;
}
async function serialize() {
if (!selected) return null;
if (!serialized) {
const dataUrl = await readDataUrl(selected);
const marker = ';base64,';
const markerAt = String(dataUrl).indexOf(marker);
if (markerAt < 0) throw new Error('The screenshot could not be read. Choose it again.');
serialized = {
filename: selected.name,
contentType: selected.type,
data: String(dataUrl).slice(markerAt + marker.length),
};
}
return { ...serialized };
}
async function prepareComment(item, body) {
const text = String(body || '').trim();
if (!selected) return text;
if (!confirmed) {
const attachment = await serialize();
confirmed = await upload({
repository: item.repository,
number: item.number,
filename: attachment.filename,
content_type: attachment.contentType,
data: attachment.data,
operation_id: operationId,
});
if (!confirmed || typeof confirmed.markdown !== 'string' || !confirmed.markdown) {
confirmed = null;
throw new Error('The server did not confirm the screenshot upload.');
}
}
return text ? text + '\n\n' + confirmed.markdown : confirmed.markdown;
}
return { select, restore, clear, state, serialize, prepareComment };
}
function mount(options) {
const controller = create(options);
const clearSelection = controller.clear;
let previewUrl = '';
function clearPreview() {
if (previewUrl) options.revokeObjectURL(previewUrl);
previewUrl = '';
options.image.src = '';
options.preview.hidden = true;
options.input.value = '';
clearSelection();
}
options.input.addEventListener('change', event => {
const file = event.target.files && event.target.files[0];
try {
controller.select(file);
} catch (error) {
options.status.textContent = error.message;
options.input.value = '';
return;
}
if (previewUrl) options.revokeObjectURL(previewUrl);
previewUrl = options.createObjectURL(file);
options.image.src = previewUrl;
options.meta.textContent = file.name + ' · ' + Math.ceil(file.size / 1024) + ' KB';
options.preview.hidden = false;
options.status.textContent = options.readyMessage || 'Screenshot ready to upload with this comment.';
});
options.remove.addEventListener('click', () => {
clearPreview();
options.status.textContent = options.removedMessage || 'Screenshot removed. Your comment is unchanged.';
});
function restorePreview(value) {
clearPreview();
const restored = controller.restore(value);
previewUrl = 'data:' + value.contentType + ';base64,' + value.data;
options.image.src = previewUrl;
options.meta.textContent = restored.name + ' · ' + Math.ceil(restored.size / 1024) + ' KB';
options.preview.hidden = false;
options.status.textContent = options.readyMessage || 'Screenshot ready to upload with this comment.';
return restored;
}
return Object.assign(controller, { clear: clearPreview, restore: restorePreview });
}
return { create, mount, MAX_BYTES };
});