feat: capture cold-offline work for filing (#301)
This commit is contained in:
1 parent
de8227f4f4
commit
9f6476a5b4
8 files changed
+244
-11
No files matched your search
@@ -81,6 +81,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.draft-preview { color:var(--muted); overflow-wrap:anywhere; }
|
||||
.draft-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
|
||||
.draft-actions button { min-height:44px; width:100%; }
|
||||
.create-issue-actions button { min-height:44px; max-width:100%; }
|
||||
.my-work-card { min-height: 44px; display:grid; gap:8px; padding:12px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; color:var(--text); }
|
||||
.my-work-card-main { display:block; width:100%; color:var(--text); text-align:left; font:inherit; background:transparent; border:0; padding:0; }
|
||||
.my-work-card-main.review-trigger { width:100%; text-align:left; font:inherit; }
|
||||
|
||||
+55
-8
@@ -168,6 +168,11 @@
|
||||
loadMilestones: item => issueController.loadMilestones(item),
|
||||
});
|
||||
const issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||
const unfiledCaptures = createUnfiledCaptures({
|
||||
storage: localStorage,
|
||||
getCaptureLogin: () => String(lastContextSnapshot?.user?.login || '').trim(),
|
||||
getCurrentLogin: () => activeFlushLogin,
|
||||
});
|
||||
let backgroundIssueSync = null;
|
||||
if ('indexedDB' in window) {
|
||||
const backgroundIssueStore = createIssueSyncStore();
|
||||
@@ -619,8 +624,21 @@
|
||||
refreshMyWorkView();
|
||||
}
|
||||
|
||||
function listDrafts() {
|
||||
const unfiled = unfiledCaptures.list().map(item => ({
|
||||
id:'unfiled:' + item.id, capture_id:item.id, kind:'unfiled-issue', label:'Needs filing',
|
||||
title:item.title, preview:item.body, copy_text:[item.title, item.body].filter(Boolean).join('\n\n'),
|
||||
updated_at:item.savedAt, quarantined:item.quarantined,
|
||||
ownership:item.quarantined ? 'Saved by ' + item.ownerLogin +
|
||||
(activeFlushLogin ? ' — current account is ' + activeFlushLogin : ' — reconnect to confirm this account') : '',
|
||||
}));
|
||||
return draftInbox.list().concat(unfiled).sort((left, right) =>
|
||||
Number(right.updated_at || 0) - Number(left.updated_at || 0)
|
||||
);
|
||||
}
|
||||
|
||||
function refreshMyWorkView() {
|
||||
lastDrafts = draftInbox.list();
|
||||
lastDrafts = listDrafts();
|
||||
const partitioned = laterWork.partition(lastMyWork, {
|
||||
pruneMissing: !Object.values(workPagination).some(page => page?.has_more),
|
||||
});
|
||||
@@ -687,6 +705,7 @@
|
||||
const list = qs('#my-work-list');
|
||||
list.innerHTML = lastDrafts.length ? lastDrafts.map((item, index) => {
|
||||
const isOutbox = item.kind === 'issue-outbox' || item.kind === 'authored-outbox';
|
||||
const isUnfiled = item.kind === 'unfiled-issue';
|
||||
const outboxActions = item.quarantined ?
|
||||
'<button class="draft-copy" data-draft-index="' + index + '" type="button">Copy content</button>' +
|
||||
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard</button>' :
|
||||
@@ -698,11 +717,12 @@
|
||||
'<button class="draft-resume" data-draft-index="' + index + '" type="button">Open message</button>' +
|
||||
'<button class="draft-send" data-draft-index="' + index + '" type="button">Send now</button>' +
|
||||
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard</button>' :
|
||||
'<button class="draft-resume" data-draft-index="' + index + '" type="button">Resume draft</button>' +
|
||||
'<button class="draft-resume" data-draft-index="' + index + '" type="button">' +
|
||||
(isUnfiled ? 'Choose repository' : 'Resume draft') + '</button>' +
|
||||
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard draft</button>';
|
||||
const state = isOutbox ?
|
||||
const state = (isOutbox || isUnfiled) ?
|
||||
'<span class="pill">' + (item.quarantined ? 'Identity protected' :
|
||||
(item.status === 'attention' ? 'Needs attention' : 'Queued for sync')) + '</span>' +
|
||||
(isUnfiled ? 'Needs filing' : item.status === 'attention' ? 'Needs attention' : 'Queued for sync')) + '</span>' +
|
||||
(item.ownership ? '<div class="small">' + escapeHtml(item.ownership) + '</div>' : '') : '';
|
||||
return '<article class="my-work-card draft-card">' +
|
||||
'<span class="small">' + escapeHtml(item.label) + (item.repository ? ' · ' + escapeHtml(item.repository) : '') + '</span>' +
|
||||
@@ -715,7 +735,15 @@
|
||||
button.addEventListener('click', () => {
|
||||
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
||||
if (!item) return;
|
||||
if (item.kind === 'new-issue') openCreateIssueSheet();
|
||||
if (item.kind === 'unfiled-issue') {
|
||||
try {
|
||||
const resumed = unfiledCaptures.resume(item.capture_id, activeFlushLogin);
|
||||
issueCapture.saveDraft(resumed);
|
||||
refreshMyWorkView();
|
||||
openCreateIssueSheet();
|
||||
qs('#create-issue-status').textContent = 'Capture restored. Choose a repository to file it.';
|
||||
} catch (error) { qs('#my-work-action-status').textContent = error.message; }
|
||||
} else if (item.kind === 'new-issue') openCreateIssueSheet();
|
||||
else if (item.route) workRoute.open(item.route);
|
||||
});
|
||||
});
|
||||
@@ -751,10 +779,11 @@
|
||||
button.addEventListener('click', () => {
|
||||
if (!window.confirm('Discard this unfinished draft?')) return;
|
||||
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
||||
if (item?.kind === 'issue-outbox') issueOutbox.discard(item.outbox_id);
|
||||
if (item?.kind === 'unfiled-issue') unfiledCaptures.discard(item.capture_id);
|
||||
else if (item?.kind === 'issue-outbox') issueOutbox.discard(item.outbox_id);
|
||||
else if (item?.kind === 'authored-outbox') authoredOutbox.discard(item.outbox_id);
|
||||
else if (item) draftInbox.discard(item.id);
|
||||
lastDrafts = draftInbox.list();
|
||||
lastDrafts = listDrafts();
|
||||
const count = qs('[data-work-count="draft"]');
|
||||
if (count) count.textContent = lastDrafts.length;
|
||||
renderDrafts();
|
||||
@@ -779,7 +808,7 @@
|
||||
}
|
||||
|
||||
function renderMyWork() {
|
||||
lastDrafts = draftInbox.list();
|
||||
lastDrafts = listDrafts();
|
||||
const draftCount = qs('[data-work-count="draft"]');
|
||||
if (draftCount) draftCount.textContent = lastDrafts.length;
|
||||
if (selectedWorkFilter === 'draft') {
|
||||
@@ -2102,6 +2131,24 @@
|
||||
}
|
||||
});
|
||||
qs('#new-issue').addEventListener('click', openCreateIssueSheet);
|
||||
qs('#save-unfiled-issue').addEventListener('click', () => {
|
||||
const captureDraft = {
|
||||
title: qs('#create-issue-title').value.trim(),
|
||||
body: qs('#create-issue-body').value.trim(),
|
||||
};
|
||||
try {
|
||||
unfiledCaptures.save(captureDraft);
|
||||
issueCapture.clearDraft();
|
||||
qs('#create-issue-title').value = '';
|
||||
qs('#create-issue-body').value = '';
|
||||
closeCreateIssueSheet();
|
||||
refreshMyWorkView();
|
||||
qs('#my-work-action-status').textContent = 'Saved in Drafts · choose a repository after reconnecting.';
|
||||
} catch (error) {
|
||||
qs('#create-issue-status').textContent = error.message;
|
||||
qs('#create-issue-title').focus();
|
||||
}
|
||||
});
|
||||
qs('#use-shared-content').addEventListener('click', () => {
|
||||
issueCapture.acceptSharedContent();
|
||||
qs('#shared-content-conflict').hidden = true;
|
||||
|
||||
@@ -335,6 +335,7 @@
|
||||
</fieldset>
|
||||
<div class="small">The issue will be assigned to you.</div>
|
||||
<div class="create-issue-actions">
|
||||
<button id="save-unfiled-issue" type="button">Save for filing</button>
|
||||
<button id="submit-new-issue" type="submit">Create & assign to me</button>
|
||||
<div id="create-issue-status" class="small" aria-live="assertive"></div>
|
||||
</div>
|
||||
@@ -506,6 +507,7 @@
|
||||
<script src="static/search-preview.js"></script>
|
||||
<script src="static/widgets.js"></script>
|
||||
<script src="static/drafts.js"></script>
|
||||
<script src="static/unfiled-captures.js"></script>
|
||||
<script src="static/outbox-coordinator.js"></script>
|
||||
<script src="static/background-issue-sync.js"></script>
|
||||
<script src="static/issue-outbox.js"></script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v28';
|
||||
const CACHE = 'stackchain-dashboard-shell-v29';
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const SHELL = [
|
||||
BASE,
|
||||
@@ -15,6 +15,7 @@ const SHELL = [
|
||||
BASE + 'static/search-preview.js',
|
||||
BASE + 'static/widgets.js',
|
||||
BASE + 'static/drafts.js',
|
||||
BASE + 'static/unfiled-captures.js',
|
||||
BASE + 'static/outbox-coordinator.js',
|
||||
BASE + 'static/issue-outbox.js',
|
||||
BASE + 'static/authored-outbox.js',
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
function createUnfiledCaptures({
|
||||
storage,
|
||||
getCaptureLogin = () => '',
|
||||
getCurrentLogin = () => '',
|
||||
createId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
|
||||
now = () => Date.now(),
|
||||
maxItems = 20,
|
||||
}) {
|
||||
const storageKey = 'stackchain.unfiled-issues.v1';
|
||||
|
||||
function read() {
|
||||
try {
|
||||
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
|
||||
if (record?.version !== 1 || !Array.isArray(record.items)) return [];
|
||||
return record.items.filter(item =>
|
||||
item && typeof item.id === 'string' && typeof item.ownerLogin === 'string' &&
|
||||
typeof item.title === 'string' && item.title.trim() && typeof item.body === 'string'
|
||||
);
|
||||
} catch (_error) { return []; }
|
||||
}
|
||||
|
||||
function write(items) {
|
||||
storage?.setItem(storageKey, JSON.stringify({version:1, items}));
|
||||
}
|
||||
|
||||
function list() {
|
||||
const currentLogin = String(getCurrentLogin() || '').trim();
|
||||
return read().slice().sort((left, right) => Number(right.savedAt) - Number(left.savedAt))
|
||||
.map(item => ({...item, quarantined: !currentLogin || currentLogin !== item.ownerLogin}));
|
||||
}
|
||||
|
||||
function save(note) {
|
||||
const title = String(note?.title || '').trim().slice(0, 255);
|
||||
const body = String(note?.body || '').trim().slice(0, 10000);
|
||||
if (!title) throw new Error('Add a title before saving.');
|
||||
const ownerLogin = String(getCaptureLogin() || '').trim();
|
||||
if (!ownerLogin) throw new Error('Offline identity is unavailable. Reconnect once before saving private work.');
|
||||
const item = {id:String(createId()), ownerLogin, title, body, savedAt:Number(now())};
|
||||
const items = [item, ...read().filter(existing => existing.id !== item.id)].slice(0, maxItems);
|
||||
write(items);
|
||||
return item;
|
||||
}
|
||||
|
||||
function discard(id) {
|
||||
const items = read();
|
||||
const remaining = items.filter(item => item.id !== id);
|
||||
if (remaining.length === items.length) return false;
|
||||
write(remaining);
|
||||
return true;
|
||||
}
|
||||
|
||||
function resume(id, confirmedLogin) {
|
||||
const item = read().find(candidate => candidate.id === id);
|
||||
if (!item) throw new Error('This capture is no longer available.');
|
||||
if (!confirmedLogin || String(confirmedLogin).trim() !== item.ownerLogin) {
|
||||
throw new Error('Reconnect with the account that saved this capture.');
|
||||
}
|
||||
discard(id);
|
||||
return {repository:'', title:item.title, body:item.body, labelIds:[]};
|
||||
}
|
||||
|
||||
return {list, save, discard, resume};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createUnfiledCaptures;
|
||||
Reference in new issue
Block a user