stackchain-dashboard/frontend/search-preview.js
timmy 745d2529e5
All checks were successful
CI / lint (pull_request) Successful in 1m39s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
feat: continue through mobile search results (Closes #801)
2026-08-14 05:08:56 +00:00

159 lines
6.3 KiB
JavaScript

(function (root, factory) {
const createSearchPreview = factory();
if (typeof module === 'object' && module.exports) module.exports = createSearchPreview;
if (root) {
root.createSearchPreview = createSearchPreview;
root.safeSearchUrl = value => {
try {
const url = new URL(value);
return ['http:', 'https:'].includes(url.protocol) ? url.href : '';
} catch (_) { return ''; }
};
}
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
return function createSearchPreview({ fetchJson, mutate, share, session, getSession, loadMore, onNavigate, navigationRoot, onState }) {
if (Array.isArray(session)) {
getSession = session[0];
loadMore = () => session[1].loadMore();
onNavigate = session[2];
navigationRoot = document;
}
let generation = 0;
let current = null;
let mutationRequest = null;
let shareRequest = null;
let moveRequest = null;
function sameItem(left, right) {
return left && right && left.kind === right.kind && left.repository === right.repository &&
Number(left.number) === Number(right.number);
}
function navigation(item) {
const session = typeof getSession === 'function' ? getSession() : null;
const items = Array.isArray(session?.items) ? session.items : [];
const index = items.findIndex(candidate => sameItem(candidate, item));
if (index < 0) return null;
return {
position:index + 1,
total:items.length,
hasPrevious:index > 0,
hasNext:index < items.length - 1 || session.more === true,
};
}
function publish(state) {
const position = navigation(state.item || current);
if (navigationRoot) {
const bar = navigationRoot.querySelector('.search-preview-navigation');
bar.hidden = !position;
if (position) {
navigationRoot.querySelector('#search-preview-position').textContent = position.position + ' of ' + position.total;
navigationRoot.querySelector('#previous-search-result').disabled = !position.hasPrevious;
navigationRoot.querySelector('#next-search-result').disabled = !position.hasNext;
}
}
onState(position ? { ...state, navigation:position } : state);
if (state.complete && navigationRoot) {
navigationRoot.querySelector('#search-preview-status').textContent = 'Search pass complete.';
}
}
function run(action, pending, success, detail) {
if (mutationRequest) return mutationRequest;
publish({ status: pending, item: current, detail });
mutationRequest = mutate(detail, action).then(result => {
publish({ status: success, item: current, detail, result });
return result;
}).catch(error => {
publish({ status: 'ready', item: current, detail, error });
throw error;
}).finally(() => { mutationRequest = null; });
return mutationRequest;
}
const api = {
open(item) {
generation += 1;
const requestGeneration = generation;
current = { ...item };
publish({ status: 'loading', item: current });
return fetchJson(current).then(detail => {
if (requestGeneration === generation) {
current = { ...current, ...detail };
publish({ status: 'ready', item: current, detail });
}
return detail;
}).catch(error => {
if (requestGeneration === generation) {
publish({ status: 'error', item: current, error });
}
throw error;
});
},
close() {
generation += 1;
current = null;
onState({ status: 'closed' });
},
previous() {
const session = typeof getSession === 'function' ? getSession() : null;
const items = Array.isArray(session?.items) ? session.items : [];
const index = items.findIndex(item => sameItem(item, current));
if (index <= 0) return Promise.resolve(current);
const item = items[index - 1];
if (typeof onNavigate === 'function') onNavigate(item);
return api.open(item);
},
next() {
if (moveRequest) return moveRequest;
moveRequest = (async () => {
let session = typeof getSession === 'function' ? getSession() : null;
let items = Array.isArray(session?.items) ? session.items : [];
let index = items.findIndex(item => sameItem(item, current));
if (index < 0) return current;
if (index === items.length - 1 && session?.more === true && typeof loadMore === 'function') {
await loadMore();
session = getSession();
items = Array.isArray(session?.items) ? session.items : [];
index = items.findIndex(item => sameItem(item, current));
}
if (index >= 0 && index < items.length - 1) {
const item = items[index + 1];
if (typeof onNavigate === 'function') onNavigate(item);
return api.open(item);
}
publish({ status:'ready', item:current, detail:current, complete:true });
return current;
})().finally(() => { moveRequest = null; });
return moveRequest;
},
share(url) {
if (shareRequest) return shareRequest;
if (!current || typeof share !== 'function') return Promise.reject(new Error('Sharing is unavailable.'));
const detail = current;
publish({ status:'sharing', item:current, detail });
shareRequest = share(url).then(result => {
publish({ status:result, item:current, detail });
return result;
}).catch(error => {
publish({ status:error?.name === 'AbortError' ? 'share-canceled' : 'share-error', item:current, detail, error });
throw error;
}).finally(() => { shareRequest = null; });
return shareRequest;
},
claim(detail) {
return run('claim', 'claiming', 'claimed', detail);
},
reopen(detail) {
return run('reopen', 'reopening', 'reopened', detail);
},
};
if (navigationRoot) {
navigationRoot.querySelector('#previous-search-result').addEventListener('click', () => api.previous().catch(() => {}));
navigationRoot.querySelector('#next-search-result').addEventListener('click', () => api.next().catch(() => {}));
}
return api;
};
});