stackchain-dashboard/frontend/today-progress.js

230 lines
10 KiB
JavaScript

function createTodayProgress({ storage, getLogin = () => '', admit, makeId = () =>
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
maxLength = 2000, maxItems = 20 }) {
const prefix = 'stackchain.today-progress.v1.';
const login = () => String(getLogin() || '').trim().toLowerCase();
const storageKey = () => login() ? prefix + encodeURIComponent(login()) : '';
const validIdentity = identity => typeof identity === 'string' && identity.length > 0 && identity.length <= 500;
const validRecord = record => record && typeof record.body === 'string' && (record.body.length > 0 || record.has_attachments === true) &&
record.body.length <= maxLength && typeof record.operation_id === 'string' && record.operation_id.length > 0 &&
record.operation_id.length <= 128;
function read() {
const key = storageKey();
if (!storage || !key) return {};
try {
const saved = JSON.parse(storage.getItem(key) || 'null');
if (!saved || saved.version !== 1 || !saved.drafts || typeof saved.drafts !== 'object' ||
Array.isArray(saved.drafts)) return {};
const entries = Object.entries(saved.drafts).filter(([identity, record]) =>
validIdentity(identity) && validRecord(record)
).slice(0, maxItems);
return Object.fromEntries(entries);
} catch (_error) {
return {};
}
}
function write(drafts) {
const key = storageKey();
if (!storage || !key) return false;
try {
if (Object.keys(drafts).length) storage.setItem(key, JSON.stringify({ version:1, drafts }));
else storage.removeItem(key);
return true;
} catch (_error) {
return false;
}
}
function load(identity) {
if (!validIdentity(identity)) return '';
return read()[identity]?.body || '';
}
function save(identity, value, hasAttachments = false) {
if (!validIdentity(identity) || !storageKey()) return false;
const body = String(value || '').trim();
if (body.length > maxLength) return false;
const drafts = read();
if (!body && !hasAttachments) {
delete drafts[identity];
return write(drafts);
}
if (!drafts[identity] && Object.keys(drafts).length >= maxItems) return false;
const previous = drafts[identity];
drafts[identity] = {
body,
operation_id: previous?.body === body ? previous.operation_id : String(makeId()).slice(0, 128),
...(hasAttachments ? {has_attachments:true} : {}),
};
return write(drafts);
}
function validTarget(target) {
return target && ['issue', 'pull'].includes(target.kind) && validIdentity(target.identity) &&
typeof target.repository === 'string' && target.repository.length > 0 && target.repository.length <= 200 &&
Number.isInteger(target.number) && target.number > 0;
}
async function post(target, value, attachments = [], completeEvidence) {
if (!validTarget(target)) throw new Error('An active Today issue or pull request is required.');
const evidence = (Array.isArray(attachments) ? attachments : [attachments]).filter(Boolean).slice(0, 5);
if ((value !== undefined || evidence.length) && !save(target.identity, value ?? load(target.identity), evidence.length > 0)) {
throw new Error('Progress update could not be saved on this device.');
}
const record = read()[target.identity];
if (!record) throw new Error('Write a progress update before posting.');
if (typeof admit !== 'function') throw new Error('Progress update delivery is unavailable.');
const admission = await admit({
kind:target.kind + '-comment', repository:target.repository, number:target.number,
body:record.body, operationId:record.operation_id,
...(evidence.length ? {attachments:evidence} : {}),
});
if (typeof completeEvidence === 'function') await completeEvidence();
const drafts = read();
if (drafts[target.identity]?.operation_id === record.operation_id) {
delete drafts[target.identity];
write(drafts);
}
return admission;
}
return { load, save, discard:identity => save(identity, ''), post };
}
function createTodayProgressView({ progress, currentTarget, qs, photos, announce = () => {}, onAdmitted = () => {} }) {
const sheet = qs('#today-progress-sheet');
const body = qs('#today-progress-body');
const status = qs('#today-progress-status');
const launcher = qs('[data-mobile-today-update]');
let openedTarget = null;
const update = () => { launcher.hidden = !currentTarget(); };
const checkpoint = async () => {
if (!openedTarget) return false;
if (progress.save(openedTarget.identity, body.value, photos?.has?.())) {
try { await photos?.checkpoint?.(); return true; }
catch (error) { status.textContent = error.message + ' Your photos remain here; retry.'; return false; }
}
status.textContent = 'Update must be 2,000 characters or fewer and device storage must be available.';
return false;
};
const close = () => {
if (sheet.open) sheet.close();
openedTarget = null;
};
launcher.addEventListener('click', async () => {
const target = currentTarget();
if (!target) return;
openedTarget = target;
qs('#today-progress-target').textContent = target.label + (target.title ? ' · ' + target.title : '');
body.value = progress.load(target.identity);
status.textContent = 'Restoring saved photo evidence…';
sheet.showModal();
try { await photos?.open?.(target); status.textContent = ''; }
catch (error) { status.textContent = error.message + ' You can retry by reopening this update.'; }
body.focus();
});
qs('#cancel-today-progress').addEventListener('click', async () => {
if (await checkpoint()) close();
});
sheet.addEventListener('cancel', async event => {
event.preventDefault();
if (await checkpoint()) close();
});
qs('#save-today-progress').addEventListener('click', async () => {
if (!await checkpoint()) return;
announce('Progress update saved privately to this Today item.');
close();
});
qs('#post-today-progress').addEventListener('click', async () => {
const target = currentTarget();
if (!target || target.identity !== openedTarget?.identity) {
status.textContent = 'The active Today item changed. Close and open its update again.';
return;
}
const button = qs('#post-today-progress');
button.disabled = true;
status.textContent = 'Saving for delivery…';
try {
await photos?.checkpoint?.();
const attachments = await photos?.serialize?.() || [];
const admission = await progress.post(target, body.value, attachments, () => photos?.complete?.());
onAdmitted(admission);
announce(admission.background ?
'Progress update queued for delivery. Today is still on the same item.' :
'Progress update saved for next launch. Today is still on the same item.');
close();
} catch (error) {
status.textContent = error.message + ' Your update remains on this item; retry.';
body.focus();
} finally {
button.disabled = false;
}
});
update();
return { update };
}
function createTodayProgressPhotos({ qs, document, issueAttachment, fetchJson, createStore, createDrafts, getLogin }) {
let target = null;
let drafts = null;
const controller = issueAttachment.mount({
maxFiles:5, input:qs('#today-progress-attachment'),
inputs:[qs('#take-today-progress-photo'), qs('#today-progress-attachment')],
preview:qs('#today-progress-attachment-preview'), image:qs('#today-progress-attachment-image'),
meta:qs('#today-progress-attachment-meta'), remove:qs('#remove-today-progress-attachment'),
tray:qs('#today-progress-attachment-tray'), earlier:qs('#move-today-progress-attachment-earlier'),
later:qs('#move-today-progress-attachment-later'), note:qs('#today-progress-attachment-note'),
noteLabel:qs('#today-progress-attachment-note-label'), status:qs('#today-progress-status'),
onChange:() => drafts?.checkpoint('today').catch(() => {}),
onCheckpoint:() => drafts.checkpoint('today'),
readyMessage:'Photo ready to post with this Today update.',
removedMessage:'Photo removed. Your progress text is unchanged.',
editor:{
document, edit:qs('#edit-today-progress-attachment'), dialog:qs('#issue-evidence-editor'),
canvas:qs('#issue-evidence-editor-canvas'), exportCanvas:qs('#issue-evidence-editor-export'),
crop:qs('#crop-issue-evidence'), redact:qs('#redact-issue-evidence'),
highlight:qs('#highlight-issue-evidence'), arrow:qs('#arrow-issue-evidence'),
undo:qs('#undo-issue-evidence-edit'), reset:qs('#reset-issue-evidence-edit'),
cancel:qs('#cancel-issue-evidence-edit'), apply:qs('#apply-issue-evidence-edit'),
status:qs('#issue-evidence-editor-status'), appliedMessage:'Edited photo ready for this Today update.',
},
createObjectURL:file => URL.createObjectURL(file), revokeObjectURL:url => URL.revokeObjectURL(url),
upload:payload => {
if (!target || target.repository !== payload.repository || Number(target.number) !== Number(payload.number)) {
return Promise.reject(new Error('The active Today item changed. Reopen its update before posting.'));
}
const repository = payload.repository.split('/').map(encodeURIComponent).join('/');
const resource = target.kind === 'pull' ? 'pulls' : 'issues';
return fetchJson('api/v1/repos/' + repository + '/' + resource + '/' +
encodeURIComponent(payload.number) + '/attachments', {
method:'POST', headers:{Accept:'application/json','Idempotency-Key':payload.operation_id},
body:issueAttachment.multipart(payload),
});
},
});
const store = createStore({ indexedDB:globalThis.indexedDB, getOwnerLogin:getLogin, scope:'today-progress' });
drafts = createDrafts({ store, lanes:{ today:{ controller, onError:error => {
qs('#today-progress-status').textContent = error.message + ' Your photos remain here; retry.';
} } } });
return {
has:() => Boolean(controller.state()), serialize:() => controller.serialize(),
checkpoint:() => drafts.checkpoint('today'),
open:async value => {
if (drafts.hasTarget('today')) await drafts.switchTo('today', value);
else await drafts.open('today', value);
target = { ...value };
},
complete:async () => { await drafts.complete('today'); target = null; },
};
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = createTodayProgress;
module.exports.createView = createTodayProgressView;
module.exports.createPhotos = createTodayProgressPhotos;
}