@storyshelf/app 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +18 -0
- package/dist/app-types.d.mts +54 -0
- package/dist/asset-manifest.mjs +41 -0
- package/dist/asset-manifest.mjs.map +1 -0
- package/dist/asset-paths.mjs +23 -0
- package/dist/asset-paths.mjs.map +1 -0
- package/dist/assets/htmx.min.js +3304 -0
- package/dist/assets/mark.svg +1 -0
- package/dist/capture-setup.mjs +139 -0
- package/dist/capture-setup.mjs.map +1 -0
- package/dist/credential-keys.mjs +20 -0
- package/dist/credential-keys.mjs.map +1 -0
- package/dist/credentials.mjs +56 -0
- package/dist/credentials.mjs.map +1 -0
- package/dist/index.d.mts +18 -0
- package/dist/index.mjs +310 -0
- package/dist/index.mjs.map +1 -0
- package/dist/lifecycle.mjs +132 -0
- package/dist/lifecycle.mjs.map +1 -0
- package/dist/middleware/auth-gate.mjs +36 -0
- package/dist/middleware/auth-gate.mjs.map +1 -0
- package/dist/middleware/csrf.mjs +75 -0
- package/dist/middleware/csrf.mjs.map +1 -0
- package/dist/middleware/rate-limit.mjs +68 -0
- package/dist/middleware/rate-limit.mjs.map +1 -0
- package/dist/middleware/request-log.mjs +30 -0
- package/dist/middleware/request-log.mjs.map +1 -0
- package/dist/middleware/server-timing.mjs +39 -0
- package/dist/middleware/server-timing.mjs.map +1 -0
- package/dist/middleware/setup-gate.mjs +37 -0
- package/dist/middleware/setup-gate.mjs.map +1 -0
- package/dist/middleware/store-scope.mjs +33 -0
- package/dist/middleware/store-scope.mjs.map +1 -0
- package/dist/notify.d.mts +17 -0
- package/dist/notify.mjs +141 -0
- package/dist/notify.mjs.map +1 -0
- package/dist/pages/admin-system.mjs +243 -0
- package/dist/pages/admin-system.mjs.map +1 -0
- package/dist/pages/build-detail.mjs +392 -0
- package/dist/pages/build-detail.mjs.map +1 -0
- package/dist/pages/build-diff-actions.mjs +162 -0
- package/dist/pages/build-diff-actions.mjs.map +1 -0
- package/dist/pages/build-diff-comments.mjs +104 -0
- package/dist/pages/build-diff-comments.mjs.map +1 -0
- package/dist/pages/build-diff-header.mjs +117 -0
- package/dist/pages/build-diff-header.mjs.map +1 -0
- package/dist/pages/build-diff-model.mjs +48 -0
- package/dist/pages/build-diff-model.mjs.map +1 -0
- package/dist/pages/build-diff-nav.mjs +122 -0
- package/dist/pages/build-diff-nav.mjs.map +1 -0
- package/dist/pages/build-diff-shortcuts.mjs +78 -0
- package/dist/pages/build-diff-shortcuts.mjs.map +1 -0
- package/dist/pages/build-diff-stale.mjs +70 -0
- package/dist/pages/build-diff-stale.mjs.map +1 -0
- package/dist/pages/build-diff-viewer.mjs +70 -0
- package/dist/pages/build-diff-viewer.mjs.map +1 -0
- package/dist/pages/build-diff.mjs +110 -0
- package/dist/pages/build-diff.mjs.map +1 -0
- package/dist/pages/compute-jobs.mjs +165 -0
- package/dist/pages/compute-jobs.mjs.map +1 -0
- package/dist/pages/invite.mjs +91 -0
- package/dist/pages/invite.mjs.map +1 -0
- package/dist/pages/label-detail.mjs +154 -0
- package/dist/pages/label-detail.mjs.map +1 -0
- package/dist/pages/library.mjs +343 -0
- package/dist/pages/library.mjs.map +1 -0
- package/dist/pages/login.mjs +117 -0
- package/dist/pages/login.mjs.map +1 -0
- package/dist/pages/passkey-ceremony.mjs +97 -0
- package/dist/pages/passkey-ceremony.mjs.map +1 -0
- package/dist/pages/profile.mjs +364 -0
- package/dist/pages/profile.mjs.map +1 -0
- package/dist/pages/project-builds-table.mjs +151 -0
- package/dist/pages/project-builds-table.mjs.map +1 -0
- package/dist/pages/project-builds.mjs +143 -0
- package/dist/pages/project-builds.mjs.map +1 -0
- package/dist/pages/project-create.mjs +90 -0
- package/dist/pages/project-create.mjs.map +1 -0
- package/dist/pages/project-settings.mjs +95 -0
- package/dist/pages/project-settings.mjs.map +1 -0
- package/dist/pages/projects-card.mjs +194 -0
- package/dist/pages/projects-card.mjs.map +1 -0
- package/dist/pages/projects.mjs +129 -0
- package/dist/pages/projects.mjs.map +1 -0
- package/dist/pages/root.mjs +98 -0
- package/dist/pages/root.mjs.map +1 -0
- package/dist/pages/settings-general.mjs +138 -0
- package/dist/pages/settings-general.mjs.map +1 -0
- package/dist/pages/settings-labels.mjs +107 -0
- package/dist/pages/settings-labels.mjs.map +1 -0
- package/dist/pages/settings-members.mjs +192 -0
- package/dist/pages/settings-members.mjs.map +1 -0
- package/dist/pages/settings-notifications.mjs +240 -0
- package/dist/pages/settings-notifications.mjs.map +1 -0
- package/dist/pages/settings-status.mjs +137 -0
- package/dist/pages/settings-status.mjs.map +1 -0
- package/dist/pages/settings-tests.mjs +111 -0
- package/dist/pages/settings-tests.mjs.map +1 -0
- package/dist/pages/settings-tokens.mjs +90 -0
- package/dist/pages/settings-tokens.mjs.map +1 -0
- package/dist/pages/settings-webhooks.mjs +104 -0
- package/dist/pages/settings-webhooks.mjs.map +1 -0
- package/dist/pages/storybook.mjs +116 -0
- package/dist/pages/storybook.mjs.map +1 -0
- package/dist/retention-timer.mjs +65 -0
- package/dist/retention-timer.mjs.map +1 -0
- package/dist/routers/admin-pages.mjs +48 -0
- package/dist/routers/admin-pages.mjs.map +1 -0
- package/dist/routers/admin.mjs +113 -0
- package/dist/routers/admin.mjs.map +1 -0
- package/dist/routers/assets.mjs +64 -0
- package/dist/routers/assets.mjs.map +1 -0
- package/dist/routers/attempts.mjs +96 -0
- package/dist/routers/attempts.mjs.map +1 -0
- package/dist/routers/auth-sync.mjs +64 -0
- package/dist/routers/auth-sync.mjs.map +1 -0
- package/dist/routers/auth.mjs +236 -0
- package/dist/routers/auth.mjs.map +1 -0
- package/dist/routers/baseline-guard.mjs +83 -0
- package/dist/routers/baseline-guard.mjs.map +1 -0
- package/dist/routers/builds.handlers.mjs +217 -0
- package/dist/routers/builds.handlers.mjs.map +1 -0
- package/dist/routers/builds.mjs +381 -0
- package/dist/routers/builds.mjs.map +1 -0
- package/dist/routers/comments.mjs +109 -0
- package/dist/routers/comments.mjs.map +1 -0
- package/dist/routers/flash.mjs +35 -0
- package/dist/routers/flash.mjs.map +1 -0
- package/dist/routers/health-report.mjs +159 -0
- package/dist/routers/health-report.mjs.map +1 -0
- package/dist/routers/health.mjs +46 -0
- package/dist/routers/health.mjs.map +1 -0
- package/dist/routers/helpers.mjs +110 -0
- package/dist/routers/helpers.mjs.map +1 -0
- package/dist/routers/htmx.mjs +24 -0
- package/dist/routers/htmx.mjs.map +1 -0
- package/dist/routers/labels.mjs +118 -0
- package/dist/routers/labels.mjs.map +1 -0
- package/dist/routers/media.mjs +53 -0
- package/dist/routers/media.mjs.map +1 -0
- package/dist/routers/members.mjs +177 -0
- package/dist/routers/members.mjs.map +1 -0
- package/dist/routers/notification-channels.mjs +190 -0
- package/dist/routers/notification-channels.mjs.map +1 -0
- package/dist/routers/notification-subscriptions.mjs +120 -0
- package/dist/routers/notification-subscriptions.mjs.map +1 -0
- package/dist/routers/profile.mjs +276 -0
- package/dist/routers/profile.mjs.map +1 -0
- package/dist/routers/projects.mjs +133 -0
- package/dist/routers/projects.mjs.map +1 -0
- package/dist/routers/review-next.mjs +47 -0
- package/dist/routers/review-next.mjs.map +1 -0
- package/dist/routers/schemas.mjs +337 -0
- package/dist/routers/schemas.mjs.map +1 -0
- package/dist/routers/settings-general.mjs +104 -0
- package/dist/routers/settings-general.mjs.map +1 -0
- package/dist/routers/settings-labels.mjs +52 -0
- package/dist/routers/settings-labels.mjs.map +1 -0
- package/dist/routers/settings-members.mjs +58 -0
- package/dist/routers/settings-members.mjs.map +1 -0
- package/dist/routers/settings-notifications.mjs +108 -0
- package/dist/routers/settings-notifications.mjs.map +1 -0
- package/dist/routers/settings-status.mjs +77 -0
- package/dist/routers/settings-status.mjs.map +1 -0
- package/dist/routers/settings-tokens.mjs +40 -0
- package/dist/routers/settings-tokens.mjs.map +1 -0
- package/dist/routers/settings-webhooks.mjs +67 -0
- package/dist/routers/settings-webhooks.mjs.map +1 -0
- package/dist/routers/settings.handlers.mjs +149 -0
- package/dist/routers/settings.handlers.mjs.map +1 -0
- package/dist/routers/settings.mjs +25 -0
- package/dist/routers/settings.mjs.map +1 -0
- package/dist/routers/snapshots.mjs +240 -0
- package/dist/routers/snapshots.mjs.map +1 -0
- package/dist/routers/status-configs.mjs +108 -0
- package/dist/routers/status-configs.mjs.map +1 -0
- package/dist/routers/storybook.mjs +171 -0
- package/dist/routers/storybook.mjs.map +1 -0
- package/dist/routers/tokens.mjs +91 -0
- package/dist/routers/tokens.mjs.map +1 -0
- package/dist/routers/ui.mjs +166 -0
- package/dist/routers/ui.mjs.map +1 -0
- package/dist/routers/webhooks.mjs +97 -0
- package/dist/routers/webhooks.mjs.map +1 -0
- package/dist/routers/well-known.mjs +43 -0
- package/dist/routers/well-known.mjs.map +1 -0
- package/dist/runtime.mjs +52 -0
- package/dist/runtime.mjs.map +1 -0
- package/dist/store.mjs +20 -0
- package/dist/store.mjs.map +1 -0
- package/dist/ui/avatar.mjs +76 -0
- package/dist/ui/avatar.mjs.map +1 -0
- package/dist/ui/buttons.mjs +205 -0
- package/dist/ui/buttons.mjs.map +1 -0
- package/dist/ui/code-block.mjs +47 -0
- package/dist/ui/code-block.mjs.map +1 -0
- package/dist/ui/compare-stage.mjs +344 -0
- package/dist/ui/compare-stage.mjs.map +1 -0
- package/dist/ui/csrf-field.mjs +20 -0
- package/dist/ui/csrf-field.mjs.map +1 -0
- package/dist/ui/css.mjs +36 -0
- package/dist/ui/css.mjs.map +1 -0
- package/dist/ui/document.mjs +130 -0
- package/dist/ui/document.mjs.map +1 -0
- package/dist/ui/dropdown.mjs +181 -0
- package/dist/ui/dropdown.mjs.map +1 -0
- package/dist/ui/feedback.mjs +341 -0
- package/dist/ui/feedback.mjs.map +1 -0
- package/dist/ui/filter-input.mjs +58 -0
- package/dist/ui/filter-input.mjs.map +1 -0
- package/dist/ui/forms.mjs +291 -0
- package/dist/ui/forms.mjs.map +1 -0
- package/dist/ui/icons/icon.mjs +53 -0
- package/dist/ui/icons/icon.mjs.map +1 -0
- package/dist/ui/icons/paths.mjs +61 -0
- package/dist/ui/icons/paths.mjs.map +1 -0
- package/dist/ui/icons/sprite.mjs +26 -0
- package/dist/ui/icons/sprite.mjs.map +1 -0
- package/dist/ui/kbd.mjs +33 -0
- package/dist/ui/kbd.mjs.map +1 -0
- package/dist/ui/layout.mjs +173 -0
- package/dist/ui/layout.mjs.map +1 -0
- package/dist/ui/progress.mjs +44 -0
- package/dist/ui/progress.mjs.map +1 -0
- package/dist/ui/segmented.mjs +77 -0
- package/dist/ui/segmented.mjs.map +1 -0
- package/dist/ui/shell/account.mjs +161 -0
- package/dist/ui/shell/account.mjs.map +1 -0
- package/dist/ui/shell/bare.mjs +69 -0
- package/dist/ui/shell/bare.mjs.map +1 -0
- package/dist/ui/shell/boot-script.mjs +46 -0
- package/dist/ui/shell/boot-script.mjs.map +1 -0
- package/dist/ui/shell/client-script.mjs +259 -0
- package/dist/ui/shell/client-script.mjs.map +1 -0
- package/dist/ui/shell/nav.mjs +111 -0
- package/dist/ui/shell/nav.mjs.map +1 -0
- package/dist/ui/shell/review-script.mjs +195 -0
- package/dist/ui/shell/review-script.mjs.map +1 -0
- package/dist/ui/shell/sidebar.mjs +323 -0
- package/dist/ui/shell/sidebar.mjs.map +1 -0
- package/dist/ui/shell/topbar.mjs +268 -0
- package/dist/ui/shell/topbar.mjs.map +1 -0
- package/dist/ui/stacks.mjs +82 -0
- package/dist/ui/stacks.mjs.map +1 -0
- package/dist/ui/styles/base.mjs +41 -0
- package/dist/ui/styles/base.mjs.map +1 -0
- package/dist/ui/styles/behavior.mjs +43 -0
- package/dist/ui/styles/behavior.mjs.map +1 -0
- package/dist/ui/styles/review-layout.mjs +347 -0
- package/dist/ui/styles/review-layout.mjs.map +1 -0
- package/dist/ui/styles/review.mjs +86 -0
- package/dist/ui/styles/review.mjs.map +1 -0
- package/dist/ui/styles/tokens.mjs +132 -0
- package/dist/ui/styles/tokens.mjs.map +1 -0
- package/dist/ui/styles.mjs +19 -0
- package/dist/ui/styles.mjs.map +1 -0
- package/dist/ui/subnav.mjs +91 -0
- package/dist/ui/subnav.mjs.map +1 -0
- package/dist/ui/table.mjs +73 -0
- package/dist/ui/table.mjs.map +1 -0
- package/dist/ui/theme.mjs +64 -0
- package/dist/ui/theme.mjs.map +1 -0
- package/dist/ui/thumbnail.mjs +51 -0
- package/dist/ui/thumbnail.mjs.map +1 -0
- package/dist/ui/time.mjs +50 -0
- package/dist/ui/time.mjs.map +1 -0
- package/package.json +92 -0
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import "node:path";
|
|
2
|
+
import "node:url";
|
|
3
|
+
import.meta.url;
|
|
4
|
+
import { iconSpriteHref } from "../icons/sprite.mjs";
|
|
5
|
+
import { reviewScript } from "./review-script.mjs";
|
|
6
|
+
//#region src/ui/shell/client-script.ts
|
|
7
|
+
/** Attach the CSRF token to every HTMX request. */
|
|
8
|
+
function csrfPart() {
|
|
9
|
+
return `
|
|
10
|
+
document.addEventListener('htmx:configRequest',function(e){
|
|
11
|
+
var meta=document.querySelector('meta[name="csrf-token"]');
|
|
12
|
+
if(meta){ e.detail.headers['x-csrf-token']=meta.getAttribute('content'); }
|
|
13
|
+
});`;
|
|
14
|
+
}
|
|
15
|
+
/** Theme menu: apply, persist, and mark the current choice. */
|
|
16
|
+
function themePart() {
|
|
17
|
+
return `
|
|
18
|
+
function markTheme(){
|
|
19
|
+
var cur=document.documentElement.getAttribute('data-theme')||'system';
|
|
20
|
+
document.querySelectorAll('[data-theme-set]').forEach(function(item){
|
|
21
|
+
item.setAttribute('aria-current', item.getAttribute('data-theme-set')===cur ? 'true' : 'false');
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
inits.push(markTheme);
|
|
25
|
+
document.addEventListener('click',function(e){
|
|
26
|
+
var item=e.target instanceof Element ? e.target.closest('[data-theme-set]') : null;
|
|
27
|
+
if(!item) return;
|
|
28
|
+
var next=item.getAttribute('data-theme-set');
|
|
29
|
+
shell.applyTheme(next);
|
|
30
|
+
shell.writeCookie(shell.themeCookie,next);
|
|
31
|
+
markTheme();
|
|
32
|
+
});`;
|
|
33
|
+
}
|
|
34
|
+
/** Desktop rail collapse (persisted) and the mobile drawer. */
|
|
35
|
+
function sidebarPart() {
|
|
36
|
+
return `
|
|
37
|
+
function setDrawer(open){
|
|
38
|
+
var sidebar=document.getElementById('sidebar');
|
|
39
|
+
if(!sidebar) return;
|
|
40
|
+
sidebar.classList.toggle('sidebar--open',open);
|
|
41
|
+
var backdrop=document.querySelector('[data-sidebar-backdrop]');
|
|
42
|
+
if(backdrop){ backdrop.hidden=!open; }
|
|
43
|
+
var btn=document.querySelector('[data-sidebar-toggle]');
|
|
44
|
+
if(btn){ btn.setAttribute('aria-expanded',open?'true':'false'); }
|
|
45
|
+
}
|
|
46
|
+
document.addEventListener('click',function(e){
|
|
47
|
+
var t=e.target instanceof Element ? e.target : null;
|
|
48
|
+
if(!t) return;
|
|
49
|
+
if(t.closest('[data-sidebar-toggle]')){
|
|
50
|
+
var sidebar=document.getElementById('sidebar');
|
|
51
|
+
setDrawer(!(sidebar&&sidebar.classList.contains('sidebar--open')));
|
|
52
|
+
} else if(t.closest('[data-sidebar-backdrop]')){
|
|
53
|
+
setDrawer(false);
|
|
54
|
+
} else if(t.closest('[data-sidebar-collapse]')){
|
|
55
|
+
var next=document.documentElement.getAttribute('data-sidebar')==='rail' ? 'full' : 'rail';
|
|
56
|
+
shell.applySidebar(next);
|
|
57
|
+
shell.writeCookie(shell.sidebarCookie,next);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
document.addEventListener('keydown',function(e){
|
|
61
|
+
var sidebar=document.getElementById('sidebar');
|
|
62
|
+
if(e.key==='Escape'&&sidebar&&sidebar.classList.contains('sidebar--open')){
|
|
63
|
+
setDrawer(false);
|
|
64
|
+
var btn=document.querySelector('[data-sidebar-toggle]');
|
|
65
|
+
if(btn) btn.focus();
|
|
66
|
+
}
|
|
67
|
+
});`;
|
|
68
|
+
}
|
|
69
|
+
/** Native <details> dropdowns: close on outside click, Escape, or choosing an item. */
|
|
70
|
+
function dropdownPart() {
|
|
71
|
+
return `
|
|
72
|
+
document.addEventListener('click',function(e){
|
|
73
|
+
var target=e.target instanceof Element ? e.target : null;
|
|
74
|
+
var owner=target ? target.closest('details[data-dropdown]') : null;
|
|
75
|
+
document.querySelectorAll('details[data-dropdown][open]').forEach(function(d){
|
|
76
|
+
if(d!==owner) d.removeAttribute('open');
|
|
77
|
+
});
|
|
78
|
+
if(owner&&target&&target.closest('[role="menuitem"]')){ owner.removeAttribute('open'); }
|
|
79
|
+
});
|
|
80
|
+
document.addEventListener('keydown',function(e){
|
|
81
|
+
if(e.key!=='Escape') return;
|
|
82
|
+
var open=document.querySelector('details[data-dropdown][open]');
|
|
83
|
+
if(!open) return;
|
|
84
|
+
open.removeAttribute('open');
|
|
85
|
+
var summary=open.querySelector('summary');
|
|
86
|
+
if(summary) summary.focus();
|
|
87
|
+
});`;
|
|
88
|
+
}
|
|
89
|
+
/** Build and show one toast element (text only, never HTML). */
|
|
90
|
+
function toastRenderPart() {
|
|
91
|
+
return `
|
|
92
|
+
var toastIcons={success:'check-circle',danger:'x-circle',warning:'alert-triangle',info:'info'};
|
|
93
|
+
function toastIcon(tone){
|
|
94
|
+
var ns='http://www.w3.org/2000/svg';
|
|
95
|
+
var svg=document.createElementNS(ns,'svg');
|
|
96
|
+
var attrs={viewBox:'0 0 24 24',width:'18',height:'18','aria-hidden':'true',fill:'none',
|
|
97
|
+
stroke:'currentColor','stroke-width':'2','stroke-linecap':'round','stroke-linejoin':'round'};
|
|
98
|
+
Object.keys(attrs).forEach(function(name){ svg.setAttribute(name,attrs[name]); });
|
|
99
|
+
var use=document.createElementNS(ns,'use');
|
|
100
|
+
use.setAttribute('href','${iconSpriteHref}#i-'+toastIcons[tone]);
|
|
101
|
+
svg.appendChild(use);
|
|
102
|
+
return svg;
|
|
103
|
+
}
|
|
104
|
+
function toast(detail){
|
|
105
|
+
var region=document.querySelector('[data-toast-region]');
|
|
106
|
+
if(!region||!detail||!detail.message) return;
|
|
107
|
+
var tone=toastIcons[detail.tone] ? detail.tone : 'info';
|
|
108
|
+
var el=document.createElement('div');
|
|
109
|
+
el.className='toast';
|
|
110
|
+
el.setAttribute('data-tone',tone);
|
|
111
|
+
el.setAttribute('role',tone==='danger'?'alert':'status');
|
|
112
|
+
var text=document.createElement('span');
|
|
113
|
+
text.textContent=String(detail.message);
|
|
114
|
+
el.appendChild(toastIcon(tone));
|
|
115
|
+
el.appendChild(text);
|
|
116
|
+
region.appendChild(el);
|
|
117
|
+
setTimeout(function(){ el.remove(); },tone==='danger'?8000:4000);
|
|
118
|
+
}`;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Toasts. Sources: an HX-Trigger(-After-Swap) "showToast" event, and a toast
|
|
122
|
+
* queued in sessionStorage by a form with data-toast (review actions reload
|
|
123
|
+
* or redirect, so the message has to survive the navigation).
|
|
124
|
+
*/
|
|
125
|
+
function toastPart() {
|
|
126
|
+
return `${toastRenderPart()}
|
|
127
|
+
var TOAST_KEY='ss_toast';
|
|
128
|
+
function queueToast(detail){ try{ sessionStorage.setItem(TOAST_KEY,JSON.stringify(detail)); }catch(_){} }
|
|
129
|
+
function clearToast(){ try{ sessionStorage.removeItem(TOAST_KEY); }catch(_){} }
|
|
130
|
+
function flushQueued(){
|
|
131
|
+
try{
|
|
132
|
+
var raw=sessionStorage.getItem(TOAST_KEY);
|
|
133
|
+
if(!raw) return;
|
|
134
|
+
sessionStorage.removeItem(TOAST_KEY);
|
|
135
|
+
toast(JSON.parse(raw));
|
|
136
|
+
}catch(_){}
|
|
137
|
+
}
|
|
138
|
+
function flushFlash(){
|
|
139
|
+
var raw=shell.readCookie('storyshelf_flash');
|
|
140
|
+
if(!raw) return;
|
|
141
|
+
document.cookie='storyshelf_flash=; Path=/; Max-Age=0; SameSite=Lax';
|
|
142
|
+
try{ toast(JSON.parse(raw)); }catch(_){}
|
|
143
|
+
}
|
|
144
|
+
function flushToast(){ flushQueued(); flushFlash(); }
|
|
145
|
+
inits.push(flushToast);
|
|
146
|
+
document.addEventListener('htmx:beforeRequest',function(e){
|
|
147
|
+
var form=e.detail && e.detail.elt && e.detail.elt.closest ? e.detail.elt.closest('[data-toast]') : null;
|
|
148
|
+
if(form){ queueToast({message:form.getAttribute('data-toast'),tone:form.getAttribute('data-toast-tone')||'success'}); }
|
|
149
|
+
});
|
|
150
|
+
function failed(){ clearToast(); toast({message:'Something went wrong. Please try again.',tone:'danger'}); }
|
|
151
|
+
document.addEventListener('htmx:responseError',failed);
|
|
152
|
+
document.addEventListener('htmx:sendError',failed);`;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Forms and body swaps. HTMX does not swap 4xx responses, but this app answers validation
|
|
156
|
+
* failures with the re-rendered page (400/409/422) so field errors show.
|
|
157
|
+
* \`data-confirm\` asks before submitting plain (non-HTMX) forms.
|
|
158
|
+
*/
|
|
159
|
+
function formPart() {
|
|
160
|
+
return `
|
|
161
|
+
document.addEventListener('htmx:beforeSwap',function(e){
|
|
162
|
+
// A body swap only replaces <body>, so classes first used by the new page
|
|
163
|
+
// would be unstyled: adopt the stylesheet the server collected for it.
|
|
164
|
+
var match=/<style id="storyshelf-css">([\\s\\S]*?)<\\/style>/.exec(e.detail.serverResponse||'');
|
|
165
|
+
var sheet=document.getElementById('storyshelf-css');
|
|
166
|
+
if(match&&sheet&&e.detail.target===document.body){ sheet.textContent=match[1]; }
|
|
167
|
+
var status=e.detail.xhr ? e.detail.xhr.status : 0;
|
|
168
|
+
if(status===400||status===409||status===422){
|
|
169
|
+
e.detail.shouldSwap=true;
|
|
170
|
+
e.detail.isError=false;
|
|
171
|
+
clearToast();
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
document.addEventListener('submit',function(e){
|
|
175
|
+
var form=e.target instanceof Element ? e.target.closest('form[data-confirm]') : null;
|
|
176
|
+
if(form&&!window.confirm(form.getAttribute('data-confirm')||'Are you sure?')){ e.preventDefault(); }
|
|
177
|
+
});`;
|
|
178
|
+
}
|
|
179
|
+
/** Copy buttons: `data-copy` holds the text; confirm with a toast. */
|
|
180
|
+
function copyPart() {
|
|
181
|
+
return `
|
|
182
|
+
document.addEventListener('click',function(e){
|
|
183
|
+
var btn=e.target instanceof Element ? e.target.closest('[data-copy]') : null;
|
|
184
|
+
if(!btn||!navigator.clipboard) return;
|
|
185
|
+
navigator.clipboard.writeText(btn.getAttribute('data-copy')||'').then(function(){
|
|
186
|
+
toast({message:'Copied to clipboard',tone:'success'});
|
|
187
|
+
},function(){
|
|
188
|
+
toast({message:'Could not copy',tone:'danger'});
|
|
189
|
+
});
|
|
190
|
+
});`;
|
|
191
|
+
}
|
|
192
|
+
/** Client-side list filtering for `[data-filter-input]` boxes. */
|
|
193
|
+
function filterPart() {
|
|
194
|
+
return `
|
|
195
|
+
document.addEventListener('input',function(e){
|
|
196
|
+
var input=e.target instanceof Element ? e.target.closest('[data-filter-input]') : null;
|
|
197
|
+
if(!input) return;
|
|
198
|
+
var scope=input.closest('[data-filter-scope]')||document;
|
|
199
|
+
var query=input.value.trim().toLowerCase();
|
|
200
|
+
var shown=0;
|
|
201
|
+
scope.querySelectorAll('[data-filter-item]').forEach(function(item){
|
|
202
|
+
var match=!query||(item.getAttribute('data-filter-text')||'').toLowerCase().indexOf(query)>-1;
|
|
203
|
+
item.hidden=!match;
|
|
204
|
+
if(match) shown++;
|
|
205
|
+
});
|
|
206
|
+
scope.querySelectorAll('[data-filter-empty]').forEach(function(el){ el.hidden=shown>0; });
|
|
207
|
+
});`;
|
|
208
|
+
}
|
|
209
|
+
/** Thin top progress bar while an HTMX request (e.g. snapshot navigation) is in flight. */
|
|
210
|
+
function loadingPart() {
|
|
211
|
+
return `
|
|
212
|
+
var pending=0;
|
|
213
|
+
function setLoading(delta){
|
|
214
|
+
pending=Math.max(0,pending+delta);
|
|
215
|
+
document.documentElement.classList.toggle('ss-loading',pending>0);
|
|
216
|
+
}
|
|
217
|
+
document.addEventListener('htmx:beforeRequest',function(){ setLoading(1); });
|
|
218
|
+
document.addEventListener('htmx:afterRequest',function(){ setLoading(-1); });`;
|
|
219
|
+
}
|
|
220
|
+
/** Keep keyboard focus sensible after HTMX swaps. */
|
|
221
|
+
function focusPart() {
|
|
222
|
+
return `
|
|
223
|
+
document.addEventListener('htmx:afterSwap',function(e){
|
|
224
|
+
var target=e.detail && e.detail.target;
|
|
225
|
+
if(target&&target.querySelector&&target!==document.body){
|
|
226
|
+
var focusable=target.querySelector('[autofocus], input, select, textarea, button');
|
|
227
|
+
if(focusable) try{ focusable.focus(); }catch(_){}
|
|
228
|
+
}
|
|
229
|
+
});`;
|
|
230
|
+
}
|
|
231
|
+
/** Page-end script wiring up the shell's interactive behavior (idempotent). */
|
|
232
|
+
function clientScript() {
|
|
233
|
+
return `
|
|
234
|
+
(function(){
|
|
235
|
+
if(window.__ssInit){ window.__ssInit(); return; }
|
|
236
|
+
var shell=window.__storyshelfShell;
|
|
237
|
+
if(!shell) return;
|
|
238
|
+
var inits=[];${[
|
|
239
|
+
csrfPart(),
|
|
240
|
+
themePart(),
|
|
241
|
+
sidebarPart(),
|
|
242
|
+
dropdownPart(),
|
|
243
|
+
toastPart(),
|
|
244
|
+
formPart(),
|
|
245
|
+
copyPart(),
|
|
246
|
+
filterPart(),
|
|
247
|
+
loadingPart(),
|
|
248
|
+
focusPart(),
|
|
249
|
+
reviewScript()
|
|
250
|
+
].join("\n")}
|
|
251
|
+
window.__ssInit=function(){ inits.forEach(function(fn){ fn(); }); };
|
|
252
|
+
window.__ssInit();
|
|
253
|
+
})();
|
|
254
|
+
`.trim();
|
|
255
|
+
}
|
|
256
|
+
//#endregion
|
|
257
|
+
export { clientScript };
|
|
258
|
+
|
|
259
|
+
//# sourceMappingURL=client-script.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client-script.mjs","names":[],"sources":["../../../src/ui/shell/client-script.ts"],"sourcesContent":["import { iconSpriteHref } from \"../icons/sprite.ts\";\nimport { reviewScript } from \"./review-script.ts\";\n\n/*\n * The page-end script is re-executed whenever HTMX swaps `body` (review\n * navigation does). So it is split in two: listeners are bound ONCE on\n * `document` and look elements up lazily; `inits` are re-run per render for\n * anything that needs wiring to freshly swapped elements.\n */\n\n/** Attach the CSRF token to every HTMX request. */\nfunction csrfPart(): string {\n return `\n document.addEventListener('htmx:configRequest',function(e){\n var meta=document.querySelector('meta[name=\"csrf-token\"]');\n if(meta){ e.detail.headers['x-csrf-token']=meta.getAttribute('content'); }\n });`;\n}\n\n/** Theme menu: apply, persist, and mark the current choice. */\nfunction themePart(): string {\n return `\n function markTheme(){\n var cur=document.documentElement.getAttribute('data-theme')||'system';\n document.querySelectorAll('[data-theme-set]').forEach(function(item){\n item.setAttribute('aria-current', item.getAttribute('data-theme-set')===cur ? 'true' : 'false');\n });\n }\n inits.push(markTheme);\n document.addEventListener('click',function(e){\n var item=e.target instanceof Element ? e.target.closest('[data-theme-set]') : null;\n if(!item) return;\n var next=item.getAttribute('data-theme-set');\n shell.applyTheme(next);\n shell.writeCookie(shell.themeCookie,next);\n markTheme();\n });`;\n}\n\n/** Desktop rail collapse (persisted) and the mobile drawer. */\nfunction sidebarPart(): string {\n return `\n function setDrawer(open){\n var sidebar=document.getElementById('sidebar');\n if(!sidebar) return;\n sidebar.classList.toggle('sidebar--open',open);\n var backdrop=document.querySelector('[data-sidebar-backdrop]');\n if(backdrop){ backdrop.hidden=!open; }\n var btn=document.querySelector('[data-sidebar-toggle]');\n if(btn){ btn.setAttribute('aria-expanded',open?'true':'false'); }\n }\n document.addEventListener('click',function(e){\n var t=e.target instanceof Element ? e.target : null;\n if(!t) return;\n if(t.closest('[data-sidebar-toggle]')){\n var sidebar=document.getElementById('sidebar');\n setDrawer(!(sidebar&&sidebar.classList.contains('sidebar--open')));\n } else if(t.closest('[data-sidebar-backdrop]')){\n setDrawer(false);\n } else if(t.closest('[data-sidebar-collapse]')){\n var next=document.documentElement.getAttribute('data-sidebar')==='rail' ? 'full' : 'rail';\n shell.applySidebar(next);\n shell.writeCookie(shell.sidebarCookie,next);\n }\n });\n document.addEventListener('keydown',function(e){\n var sidebar=document.getElementById('sidebar');\n if(e.key==='Escape'&&sidebar&&sidebar.classList.contains('sidebar--open')){\n setDrawer(false);\n var btn=document.querySelector('[data-sidebar-toggle]');\n if(btn) btn.focus();\n }\n });`;\n}\n\n/** Native <details> dropdowns: close on outside click, Escape, or choosing an item. */\nfunction dropdownPart(): string {\n return `\n document.addEventListener('click',function(e){\n var target=e.target instanceof Element ? e.target : null;\n var owner=target ? target.closest('details[data-dropdown]') : null;\n document.querySelectorAll('details[data-dropdown][open]').forEach(function(d){\n if(d!==owner) d.removeAttribute('open');\n });\n if(owner&&target&&target.closest('[role=\"menuitem\"]')){ owner.removeAttribute('open'); }\n });\n document.addEventListener('keydown',function(e){\n if(e.key!=='Escape') return;\n var open=document.querySelector('details[data-dropdown][open]');\n if(!open) return;\n open.removeAttribute('open');\n var summary=open.querySelector('summary');\n if(summary) summary.focus();\n });`;\n}\n\n/** Build and show one toast element (text only, never HTML). */\nfunction toastRenderPart(): string {\n return `\n var toastIcons={success:'check-circle',danger:'x-circle',warning:'alert-triangle',info:'info'};\n function toastIcon(tone){\n var ns='http://www.w3.org/2000/svg';\n var svg=document.createElementNS(ns,'svg');\n var attrs={viewBox:'0 0 24 24',width:'18',height:'18','aria-hidden':'true',fill:'none',\n stroke:'currentColor','stroke-width':'2','stroke-linecap':'round','stroke-linejoin':'round'};\n Object.keys(attrs).forEach(function(name){ svg.setAttribute(name,attrs[name]); });\n var use=document.createElementNS(ns,'use');\n use.setAttribute('href','${iconSpriteHref}#i-'+toastIcons[tone]);\n svg.appendChild(use);\n return svg;\n }\n function toast(detail){\n var region=document.querySelector('[data-toast-region]');\n if(!region||!detail||!detail.message) return;\n var tone=toastIcons[detail.tone] ? detail.tone : 'info';\n var el=document.createElement('div');\n el.className='toast';\n el.setAttribute('data-tone',tone);\n el.setAttribute('role',tone==='danger'?'alert':'status');\n var text=document.createElement('span');\n text.textContent=String(detail.message);\n el.appendChild(toastIcon(tone));\n el.appendChild(text);\n region.appendChild(el);\n setTimeout(function(){ el.remove(); },tone==='danger'?8000:4000);\n }`;\n}\n\n/**\n * Toasts. Sources: an HX-Trigger(-After-Swap) \"showToast\" event, and a toast\n * queued in sessionStorage by a form with data-toast (review actions reload\n * or redirect, so the message has to survive the navigation).\n */\nfunction toastPart(): string {\n return `${toastRenderPart()}\n var TOAST_KEY='ss_toast';\n function queueToast(detail){ try{ sessionStorage.setItem(TOAST_KEY,JSON.stringify(detail)); }catch(_){} }\n function clearToast(){ try{ sessionStorage.removeItem(TOAST_KEY); }catch(_){} }\n function flushQueued(){\n try{\n var raw=sessionStorage.getItem(TOAST_KEY);\n if(!raw) return;\n sessionStorage.removeItem(TOAST_KEY);\n toast(JSON.parse(raw));\n }catch(_){}\n }\n function flushFlash(){\n var raw=shell.readCookie('storyshelf_flash');\n if(!raw) return;\n document.cookie='storyshelf_flash=; Path=/; Max-Age=0; SameSite=Lax';\n try{ toast(JSON.parse(raw)); }catch(_){}\n }\n function flushToast(){ flushQueued(); flushFlash(); }\n inits.push(flushToast);\n document.addEventListener('htmx:beforeRequest',function(e){\n var form=e.detail && e.detail.elt && e.detail.elt.closest ? e.detail.elt.closest('[data-toast]') : null;\n if(form){ queueToast({message:form.getAttribute('data-toast'),tone:form.getAttribute('data-toast-tone')||'success'}); }\n });\n function failed(){ clearToast(); toast({message:'Something went wrong. Please try again.',tone:'danger'}); }\n document.addEventListener('htmx:responseError',failed);\n document.addEventListener('htmx:sendError',failed);`;\n}\n\n/**\n * Forms and body swaps. HTMX does not swap 4xx responses, but this app answers validation\n * failures with the re-rendered page (400/409/422) so field errors show.\n * \\`data-confirm\\` asks before submitting plain (non-HTMX) forms.\n */\nfunction formPart(): string {\n return `\n document.addEventListener('htmx:beforeSwap',function(e){\n // A body swap only replaces <body>, so classes first used by the new page\n // would be unstyled: adopt the stylesheet the server collected for it.\n var match=/<style id=\"storyshelf-css\">([\\\\s\\\\S]*?)<\\\\/style>/.exec(e.detail.serverResponse||'');\n var sheet=document.getElementById('storyshelf-css');\n if(match&&sheet&&e.detail.target===document.body){ sheet.textContent=match[1]; }\n var status=e.detail.xhr ? e.detail.xhr.status : 0;\n if(status===400||status===409||status===422){\n e.detail.shouldSwap=true;\n e.detail.isError=false;\n clearToast();\n }\n });\n document.addEventListener('submit',function(e){\n var form=e.target instanceof Element ? e.target.closest('form[data-confirm]') : null;\n if(form&&!window.confirm(form.getAttribute('data-confirm')||'Are you sure?')){ e.preventDefault(); }\n });`;\n}\n\n/** Copy buttons: `data-copy` holds the text; confirm with a toast. */\nfunction copyPart(): string {\n return `\n document.addEventListener('click',function(e){\n var btn=e.target instanceof Element ? e.target.closest('[data-copy]') : null;\n if(!btn||!navigator.clipboard) return;\n navigator.clipboard.writeText(btn.getAttribute('data-copy')||'').then(function(){\n toast({message:'Copied to clipboard',tone:'success'});\n },function(){\n toast({message:'Could not copy',tone:'danger'});\n });\n });`;\n}\n\n/** Client-side list filtering for `[data-filter-input]` boxes. */\nfunction filterPart(): string {\n return `\n document.addEventListener('input',function(e){\n var input=e.target instanceof Element ? e.target.closest('[data-filter-input]') : null;\n if(!input) return;\n var scope=input.closest('[data-filter-scope]')||document;\n var query=input.value.trim().toLowerCase();\n var shown=0;\n scope.querySelectorAll('[data-filter-item]').forEach(function(item){\n var match=!query||(item.getAttribute('data-filter-text')||'').toLowerCase().indexOf(query)>-1;\n item.hidden=!match;\n if(match) shown++;\n });\n scope.querySelectorAll('[data-filter-empty]').forEach(function(el){ el.hidden=shown>0; });\n });`;\n}\n\n/** Thin top progress bar while an HTMX request (e.g. snapshot navigation) is in flight. */\nfunction loadingPart(): string {\n return `\n var pending=0;\n function setLoading(delta){\n pending=Math.max(0,pending+delta);\n document.documentElement.classList.toggle('ss-loading',pending>0);\n }\n document.addEventListener('htmx:beforeRequest',function(){ setLoading(1); });\n document.addEventListener('htmx:afterRequest',function(){ setLoading(-1); });`;\n}\n\n/** Keep keyboard focus sensible after HTMX swaps. */\nfunction focusPart(): string {\n return `\n document.addEventListener('htmx:afterSwap',function(e){\n var target=e.detail && e.detail.target;\n if(target&&target.querySelector&&target!==document.body){\n var focusable=target.querySelector('[autofocus], input, select, textarea, button');\n if(focusable) try{ focusable.focus(); }catch(_){}\n }\n });`;\n}\n\n/** Page-end script wiring up the shell's interactive behavior (idempotent). */\nexport function clientScript(): string {\n const parts = [\n csrfPart(),\n themePart(),\n sidebarPart(),\n dropdownPart(),\n toastPart(),\n formPart(),\n copyPart(),\n filterPart(),\n loadingPart(),\n focusPart(),\n reviewScript(),\n ];\n return `\n (function(){\n if(window.__ssInit){ window.__ssInit(); return; }\n var shell=window.__storyshelfShell;\n if(!shell) return;\n var inits=[];${parts.join(\"\\n\")}\n window.__ssInit=function(){ inits.forEach(function(fn){ fn(); }); };\n window.__ssInit();\n })();\n `.trim();\n}\n"],"mappings":";;;;;;;AAWA,SAAS,WAAmB;CAC1B,OAAO;;;;;AAKT;;AAGA,SAAS,YAAoB;CAC3B,OAAO;;;;;;;;;;;;;;;;AAgBT;;AAGA,SAAS,cAAsB;CAC7B,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgCT;;AAGA,SAAS,eAAuB;CAC9B,OAAO;;;;;;;;;;;;;;;;;AAiBT;;AAGA,SAAS,kBAA0B;CACjC,OAAO;;;;;;;;;iCASwB,eAAe;;;;;;;;;;;;;;;;;;;AAmBhD;;;;;;AAOA,SAAS,YAAoB;CAC3B,OAAO,GAAG,gBAAgB,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;AA2B9B;;;;;;AAOA,SAAS,WAAmB;CAC1B,OAAO;;;;;;;;;;;;;;;;;;AAkBT;;AAGA,SAAS,WAAmB;CAC1B,OAAO;;;;;;;;;;AAUT;;AAGA,SAAS,aAAqB;CAC5B,OAAO;;;;;;;;;;;;;;AAcT;;AAGA,SAAS,cAAsB;CAC7B,OAAO;;;;;;;;AAQT;;AAGA,SAAS,YAAoB;CAC3B,OAAO;;;;;;;;AAQT;;AAGA,SAAgB,eAAuB;CAcrC,OAAO;;;;;mBAKU;EAjBf,SAAS;EACT,UAAU;EACV,YAAY;EACZ,aAAa;EACb,UAAU;EACV,SAAS;EACT,SAAS;EACT,WAAW;EACX,YAAY;EACZ,UAAU;EACV,aAAa;CAOM,CAAC,CAAC,KAAK,IAAI,EAAE;;;;IAIhC,KAAK;AACT"}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import "node:path";
|
|
2
|
+
import "node:url";
|
|
3
|
+
import.meta.url;
|
|
4
|
+
//#region src/ui/shell/nav.ts
|
|
5
|
+
/** Entries shown for the current project. */
|
|
6
|
+
function projectNavItems(urls, slug) {
|
|
7
|
+
return [
|
|
8
|
+
{
|
|
9
|
+
key: "library",
|
|
10
|
+
label: "Library",
|
|
11
|
+
href: urls.library(slug),
|
|
12
|
+
icon: "image"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
key: "builds",
|
|
16
|
+
label: "Builds",
|
|
17
|
+
href: urls.buildsList(slug),
|
|
18
|
+
icon: "layers"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
key: "jobs",
|
|
22
|
+
label: "Jobs",
|
|
23
|
+
href: urls.jobs(slug),
|
|
24
|
+
icon: "activity"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
key: "labels",
|
|
28
|
+
label: "Labels",
|
|
29
|
+
href: urls.labels(slug),
|
|
30
|
+
icon: "tag"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
key: "settings",
|
|
34
|
+
label: "Settings",
|
|
35
|
+
href: urls.settings(slug),
|
|
36
|
+
icon: "settings"
|
|
37
|
+
}
|
|
38
|
+
];
|
|
39
|
+
}
|
|
40
|
+
/** Developer entries (API reference). */
|
|
41
|
+
function developerNavItems() {
|
|
42
|
+
return [{
|
|
43
|
+
key: "api-docs",
|
|
44
|
+
label: "API docs",
|
|
45
|
+
href: "/api/v1/docs",
|
|
46
|
+
icon: "book"
|
|
47
|
+
}, {
|
|
48
|
+
key: "openapi",
|
|
49
|
+
label: "OpenAPI spec",
|
|
50
|
+
href: "/api/v1/openapi.json",
|
|
51
|
+
icon: "code",
|
|
52
|
+
external: true
|
|
53
|
+
}];
|
|
54
|
+
}
|
|
55
|
+
/** Administration entries (admins only). */
|
|
56
|
+
function adminNavItems() {
|
|
57
|
+
return [{
|
|
58
|
+
key: "admin",
|
|
59
|
+
label: "System",
|
|
60
|
+
href: "/admin",
|
|
61
|
+
icon: "server"
|
|
62
|
+
}];
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Breadcrumbs after the project picker: the *ancestors* of the current page.
|
|
66
|
+
* The page itself is named by its heading, so it is never repeated here —
|
|
67
|
+
* the section plus `nav.trail` describe where the page is, and the last item
|
|
68
|
+
* (the current page) is dropped. Every returned crumb links somewhere, and
|
|
69
|
+
* the last one is the page's parent.
|
|
70
|
+
*/
|
|
71
|
+
function breadcrumbs(nav, urls) {
|
|
72
|
+
const section = sectionCrumb(nav, urls);
|
|
73
|
+
return (section ? [section, ...nav?.trail ?? []] : [...nav?.trail ?? []]).slice(0, -1).filter((crumb) => crumb.href !== void 0);
|
|
74
|
+
}
|
|
75
|
+
function sectionCrumb(nav, urls) {
|
|
76
|
+
const slug = nav?.projectSlug;
|
|
77
|
+
return {
|
|
78
|
+
library: slug ? {
|
|
79
|
+
label: "Library",
|
|
80
|
+
href: urls.library(slug)
|
|
81
|
+
} : void 0,
|
|
82
|
+
builds: slug ? {
|
|
83
|
+
label: "Builds",
|
|
84
|
+
href: urls.buildsList(slug)
|
|
85
|
+
} : void 0,
|
|
86
|
+
jobs: slug ? {
|
|
87
|
+
label: "Jobs",
|
|
88
|
+
href: urls.jobs(slug)
|
|
89
|
+
} : void 0,
|
|
90
|
+
labels: slug ? {
|
|
91
|
+
label: "Labels",
|
|
92
|
+
href: urls.labels(slug)
|
|
93
|
+
} : void 0,
|
|
94
|
+
settings: slug ? {
|
|
95
|
+
label: "Settings",
|
|
96
|
+
href: urls.settings(slug)
|
|
97
|
+
} : void 0,
|
|
98
|
+
admin: {
|
|
99
|
+
label: "System",
|
|
100
|
+
href: "/admin"
|
|
101
|
+
},
|
|
102
|
+
profile: {
|
|
103
|
+
label: "Profile",
|
|
104
|
+
href: "/profile"
|
|
105
|
+
}
|
|
106
|
+
}[nav?.active ?? ""] ?? null;
|
|
107
|
+
}
|
|
108
|
+
//#endregion
|
|
109
|
+
export { adminNavItems, breadcrumbs, developerNavItems, projectNavItems };
|
|
110
|
+
|
|
111
|
+
//# sourceMappingURL=nav.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"nav.mjs","names":[],"sources":["../../../src/ui/shell/nav.ts"],"sourcesContent":["import type { IconName } from \"../icons/paths.ts\";\n\n/** Which nav entry is active and which project (if any) the page belongs to. */\nexport interface NavConfig {\n active?: string;\n projectSlug?: string;\n projectName?: string;\n /** Levels below the section, ending with the current page (e.g. a build, then \"Review\"). */\n trail?: NavCrumb[];\n}\n\n/** One breadcrumb level; the last trail item is the current page (it needs no `href`). */\nexport interface NavCrumb {\n label: string;\n href?: string;\n}\n\n/** One sidebar entry. */\nexport interface NavItem {\n key: string;\n label: string;\n href: string;\n icon: IconName;\n /** Opens in a new tab (raw API output). */\n external?: boolean;\n}\n\n/** Subset of the URL builder the nav needs (keeps this module pure). */\nexport interface NavUrls {\n projects(): string;\n library(slug: string): string;\n buildsList(slug: string): string;\n jobs(slug: string): string;\n labels(slug: string): string;\n settings(slug: string): string;\n}\n\n/** Entries shown for the current project. */\nexport function projectNavItems(urls: NavUrls, slug: string): NavItem[] {\n return [\n { key: \"library\", label: \"Library\", href: urls.library(slug), icon: \"image\" },\n { key: \"builds\", label: \"Builds\", href: urls.buildsList(slug), icon: \"layers\" },\n { key: \"jobs\", label: \"Jobs\", href: urls.jobs(slug), icon: \"activity\" },\n { key: \"labels\", label: \"Labels\", href: urls.labels(slug), icon: \"tag\" },\n { key: \"settings\", label: \"Settings\", href: urls.settings(slug), icon: \"settings\" },\n ];\n}\n\n/** Developer entries (API reference). */\nexport function developerNavItems(): NavItem[] {\n return [\n { key: \"api-docs\", label: \"API docs\", href: \"/api/v1/docs\", icon: \"book\" },\n {\n key: \"openapi\",\n label: \"OpenAPI spec\",\n href: \"/api/v1/openapi.json\",\n icon: \"code\",\n external: true,\n },\n ];\n}\n\n/** Administration entries (admins only). */\nexport function adminNavItems(): NavItem[] {\n return [{ key: \"admin\", label: \"System\", href: \"/admin\", icon: \"server\" }];\n}\n\n/**\n * Breadcrumbs after the project picker: the *ancestors* of the current page.\n * The page itself is named by its heading, so it is never repeated here —\n * the section plus `nav.trail` describe where the page is, and the last item\n * (the current page) is dropped. Every returned crumb links somewhere, and\n * the last one is the page's parent.\n */\nexport function breadcrumbs(nav: NavConfig | undefined, urls: NavUrls): NavCrumb[] {\n const section = sectionCrumb(nav, urls);\n const path = section ? [section, ...(nav?.trail ?? [])] : [...(nav?.trail ?? [])];\n return path.slice(0, -1).filter((crumb) => crumb.href !== undefined);\n}\n\nfunction sectionCrumb(nav: NavConfig | undefined, urls: NavUrls): NavCrumb | null {\n const slug = nav?.projectSlug;\n const table: Record<string, NavCrumb | undefined> = {\n library: slug ? { label: \"Library\", href: urls.library(slug) } : undefined,\n builds: slug ? { label: \"Builds\", href: urls.buildsList(slug) } : undefined,\n jobs: slug ? { label: \"Jobs\", href: urls.jobs(slug) } : undefined,\n labels: slug ? { label: \"Labels\", href: urls.labels(slug) } : undefined,\n settings: slug ? { label: \"Settings\", href: urls.settings(slug) } : undefined,\n admin: { label: \"System\", href: \"/admin\" },\n profile: { label: \"Profile\", href: \"/profile\" },\n };\n return table[nav?.active ?? \"\"] ?? null;\n}\n"],"mappings":";;;;;AAsCA,SAAgB,gBAAgB,MAAe,MAAyB;CACtE,OAAO;EACL;GAAE,KAAK;GAAW,OAAO;GAAW,MAAM,KAAK,QAAQ,IAAI;GAAG,MAAM;EAAQ;EAC5E;GAAE,KAAK;GAAU,OAAO;GAAU,MAAM,KAAK,WAAW,IAAI;GAAG,MAAM;EAAS;EAC9E;GAAE,KAAK;GAAQ,OAAO;GAAQ,MAAM,KAAK,KAAK,IAAI;GAAG,MAAM;EAAW;EACtE;GAAE,KAAK;GAAU,OAAO;GAAU,MAAM,KAAK,OAAO,IAAI;GAAG,MAAM;EAAM;EACvE;GAAE,KAAK;GAAY,OAAO;GAAY,MAAM,KAAK,SAAS,IAAI;GAAG,MAAM;EAAW;CACpF;AACF;;AAGA,SAAgB,oBAA+B;CAC7C,OAAO,CACL;EAAE,KAAK;EAAY,OAAO;EAAY,MAAM;EAAgB,MAAM;CAAO,GACzE;EACE,KAAK;EACL,OAAO;EACP,MAAM;EACN,MAAM;EACN,UAAU;CACZ,CACF;AACF;;AAGA,SAAgB,gBAA2B;CACzC,OAAO,CAAC;EAAE,KAAK;EAAS,OAAO;EAAU,MAAM;EAAU,MAAM;CAAS,CAAC;AAC3E;;;;;;;;AASA,SAAgB,YAAY,KAA4B,MAA2B;CACjF,MAAM,UAAU,aAAa,KAAK,IAAI;CAEtC,QADa,UAAU,CAAC,SAAS,GAAI,KAAK,SAAS,CAAC,CAAE,IAAI,CAAC,GAAI,KAAK,SAAS,CAAC,CAAE,EAAA,CACpE,MAAM,GAAG,EAAE,CAAC,CAAC,QAAQ,UAAU,MAAM,SAAS,KAAA,CAAS;AACrE;AAEA,SAAS,aAAa,KAA4B,MAAgC;CAChF,MAAM,OAAO,KAAK;CAUlB,OAAO;EARL,SAAS,OAAO;GAAE,OAAO;GAAW,MAAM,KAAK,QAAQ,IAAI;EAAE,IAAI,KAAA;EACjE,QAAQ,OAAO;GAAE,OAAO;GAAU,MAAM,KAAK,WAAW,IAAI;EAAE,IAAI,KAAA;EAClE,MAAM,OAAO;GAAE,OAAO;GAAQ,MAAM,KAAK,KAAK,IAAI;EAAE,IAAI,KAAA;EACxD,QAAQ,OAAO;GAAE,OAAO;GAAU,MAAM,KAAK,OAAO,IAAI;EAAE,IAAI,KAAA;EAC9D,UAAU,OAAO;GAAE,OAAO;GAAY,MAAM,KAAK,SAAS,IAAI;EAAE,IAAI,KAAA;EACpE,OAAO;GAAE,OAAO;GAAU,MAAM;EAAS;EACzC,SAAS;GAAE,OAAO;GAAW,MAAM;EAAW;CAErC,EAAE,KAAK,UAAU,OAAO;AACrC"}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import "node:path";
|
|
2
|
+
import "node:url";
|
|
3
|
+
import.meta.url;
|
|
4
|
+
//#region src/ui/shell/review-script.ts
|
|
5
|
+
/**
|
|
6
|
+
* Review workspace behavior: comparison modes, zoom, synchronized scrolling,
|
|
7
|
+
* list filters, keyboard review, and the shortcuts dialog. Runs inside the
|
|
8
|
+
* shell's once-only closure (`inits`, `shell` are in scope).
|
|
9
|
+
*/
|
|
10
|
+
function reviewScript() {
|
|
11
|
+
return [
|
|
12
|
+
stateHelpers(),
|
|
13
|
+
stageSetup(),
|
|
14
|
+
wipeDrag(),
|
|
15
|
+
clickControls(),
|
|
16
|
+
keyboardShortcuts()
|
|
17
|
+
].join("\n");
|
|
18
|
+
}
|
|
19
|
+
/** Mode/zoom state on the stage root, remembered across snapshots. */
|
|
20
|
+
function stateHelpers() {
|
|
21
|
+
return `
|
|
22
|
+
var MODES=['split','swipe','onion','diff','flip'];
|
|
23
|
+
var ZOOMS=['fit','100','200','400'];
|
|
24
|
+
function remember(key,value){ try{ localStorage.setItem(key,value); }catch(_){} }
|
|
25
|
+
function recall(key){ try{ return localStorage.getItem(key); }catch(_){ return null; } }
|
|
26
|
+
function press(group,value){
|
|
27
|
+
if(!group) return;
|
|
28
|
+
group.querySelectorAll('[data-view-value]').forEach(function(b){
|
|
29
|
+
b.setAttribute('aria-pressed', b.getAttribute('data-view-value')===value ? 'true' : 'false');
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
function setMode(stage,mode,persist){
|
|
33
|
+
stage.setAttribute('data-view',mode);
|
|
34
|
+
press(stage.querySelector('[data-compare-mode]'),mode);
|
|
35
|
+
if(persist) remember('ss_compare_mode',mode);
|
|
36
|
+
}
|
|
37
|
+
function setZoom(stage,zoom,persist){
|
|
38
|
+
stage.setAttribute('data-zoom',zoom);
|
|
39
|
+
press(stage.querySelector('[data-compare-zoom]'),zoom);
|
|
40
|
+
if(persist) remember('ss_compare_zoom',zoom);
|
|
41
|
+
}
|
|
42
|
+
function flip(stage){
|
|
43
|
+
stage.setAttribute('data-flip', stage.getAttribute('data-flip')==='baseline' ? 'current' : 'baseline');
|
|
44
|
+
}
|
|
45
|
+
function nextZoom(stage){
|
|
46
|
+
var cur=stage.getAttribute('data-zoom')||'fit';
|
|
47
|
+
setZoom(stage,ZOOMS[(ZOOMS.indexOf(cur)+1)%ZOOMS.length],true);
|
|
48
|
+
}
|
|
49
|
+
function stepZoom(stage,delta){
|
|
50
|
+
var cur=ZOOMS.indexOf(stage.getAttribute('data-zoom')||'fit');
|
|
51
|
+
var next=Math.max(0,Math.min(ZOOMS.length-1,cur+delta));
|
|
52
|
+
setZoom(stage,ZOOMS[next],true);
|
|
53
|
+
}`;
|
|
54
|
+
}
|
|
55
|
+
/** Per-render wiring of a freshly swapped-in comparison stage. */
|
|
56
|
+
function stageSetup() {
|
|
57
|
+
return `
|
|
58
|
+
function syncScroll(stage){
|
|
59
|
+
var panes=Array.from(stage.querySelectorAll('.stage__pane'));
|
|
60
|
+
var lock=false;
|
|
61
|
+
panes.forEach(function(pane){
|
|
62
|
+
pane.addEventListener('scroll',function(){
|
|
63
|
+
if(lock) return;
|
|
64
|
+
lock=true;
|
|
65
|
+
panes.forEach(function(other){
|
|
66
|
+
if(other!==pane){ other.scrollTop=pane.scrollTop; other.scrollLeft=pane.scrollLeft; }
|
|
67
|
+
});
|
|
68
|
+
requestAnimationFrame(function(){ lock=false; });
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
function bindRange(stage,selector,prop,scale){
|
|
73
|
+
var input=stage.querySelector(selector);
|
|
74
|
+
if(!input) return;
|
|
75
|
+
input.addEventListener('input',function(){ stage.style.setProperty(prop,String(input.value*scale)+(scale===1?'%':'')); });
|
|
76
|
+
}
|
|
77
|
+
function initStage(stage){
|
|
78
|
+
if(stage.getAttribute('data-ready')) return;
|
|
79
|
+
stage.setAttribute('data-ready','1');
|
|
80
|
+
var mode=recall('ss_compare_mode');
|
|
81
|
+
if(MODES.indexOf(mode)>-1) setMode(stage,mode,false);
|
|
82
|
+
var zoom=recall('ss_compare_zoom');
|
|
83
|
+
if(ZOOMS.indexOf(zoom)>-1) setZoom(stage,zoom,false);
|
|
84
|
+
bindRange(stage,'[data-compare-swipe]','--swipe',1);
|
|
85
|
+
bindRange(stage,'[data-compare-onion]','--onion',0.01);
|
|
86
|
+
var grid=stage.querySelector('.stage__grid');
|
|
87
|
+
if(grid){ grid.addEventListener('click',function(){ if(stage.getAttribute('data-view')==='flip'){ flip(stage); } }); }
|
|
88
|
+
syncScroll(stage);
|
|
89
|
+
}
|
|
90
|
+
inits.push(function(){ document.querySelectorAll('[data-compare]').forEach(initStage); });`;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Drag the wipe handle to move the swipe divider. Uses pointer capture so the
|
|
94
|
+
* drag keeps working when the pointer leaves the handle, and mirrors the value
|
|
95
|
+
* into the (accessible) range slider.
|
|
96
|
+
*/
|
|
97
|
+
function wipeDrag() {
|
|
98
|
+
return `
|
|
99
|
+
function wipePercent(stage,clientX){
|
|
100
|
+
var pane=stage.querySelector('.stage__pane[data-pane="current"]');
|
|
101
|
+
if(!pane) return 50;
|
|
102
|
+
var rect=pane.getBoundingClientRect();
|
|
103
|
+
return Math.max(0,Math.min(100,(clientX-rect.left)/rect.width*100));
|
|
104
|
+
}
|
|
105
|
+
function setWipe(stage,percent){
|
|
106
|
+
stage.style.setProperty('--swipe',percent+'%');
|
|
107
|
+
var range=stage.querySelector('[data-compare-swipe]');
|
|
108
|
+
if(range){ range.value=String(Math.round(percent)); }
|
|
109
|
+
}
|
|
110
|
+
function bindKnob(stage){
|
|
111
|
+
var knob=stage.querySelector('[data-compare-knob]');
|
|
112
|
+
if(!knob||knob.getAttribute('data-bound')) return;
|
|
113
|
+
knob.setAttribute('data-bound','1');
|
|
114
|
+
knob.addEventListener('pointerdown',function(e){
|
|
115
|
+
e.preventDefault();
|
|
116
|
+
knob.setPointerCapture(e.pointerId);
|
|
117
|
+
setWipe(stage,wipePercent(stage,e.clientX));
|
|
118
|
+
function move(ev){ setWipe(stage,wipePercent(stage,ev.clientX)); }
|
|
119
|
+
function stop(){
|
|
120
|
+
knob.removeEventListener('pointermove',move);
|
|
121
|
+
knob.removeEventListener('pointerup',stop);
|
|
122
|
+
knob.removeEventListener('pointercancel',stop);
|
|
123
|
+
}
|
|
124
|
+
knob.addEventListener('pointermove',move);
|
|
125
|
+
knob.addEventListener('pointerup',stop);
|
|
126
|
+
knob.addEventListener('pointercancel',stop);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
inits.push(function(){ document.querySelectorAll('[data-compare]').forEach(bindKnob); });`;
|
|
130
|
+
}
|
|
131
|
+
/** Delegated clicks: mode/zoom/filter segments and the shortcuts button. */
|
|
132
|
+
function clickControls() {
|
|
133
|
+
return `
|
|
134
|
+
function openShortcuts(){
|
|
135
|
+
var dialog=document.querySelector('dialog[data-shortcuts]');
|
|
136
|
+
if(dialog&&typeof dialog.showModal==='function'&&!dialog.open){ dialog.showModal(); }
|
|
137
|
+
}
|
|
138
|
+
function chooseSegment(btn){
|
|
139
|
+
var value=btn.getAttribute('data-view-value');
|
|
140
|
+
var stage=btn.closest('[data-compare]');
|
|
141
|
+
if(stage&&btn.closest('[data-compare-mode]')){ setMode(stage,value,true); }
|
|
142
|
+
else if(stage&&btn.closest('[data-compare-zoom]')){ setZoom(stage,value,true); }
|
|
143
|
+
else if(btn.closest('[data-filter-switch]')){
|
|
144
|
+
var list=document.querySelector('[data-diff-nav]');
|
|
145
|
+
if(list){ list.setAttribute('data-filter',value); }
|
|
146
|
+
press(btn.closest('[data-filter-switch]'),value);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
document.addEventListener('click',function(e){
|
|
150
|
+
var t=e.target instanceof Element ? e.target : null;
|
|
151
|
+
if(!t) return;
|
|
152
|
+
var btn=t.closest('[data-view-value]');
|
|
153
|
+
if(btn){ chooseSegment(btn); }
|
|
154
|
+
if(t.closest('[data-shortcuts-open]')){ openShortcuts(); }
|
|
155
|
+
});`;
|
|
156
|
+
}
|
|
157
|
+
/** Global review shortcuts; ignored while typing or with modifier keys held. */
|
|
158
|
+
function keyboardShortcuts() {
|
|
159
|
+
return `
|
|
160
|
+
function typing(target){
|
|
161
|
+
return target instanceof HTMLElement &&
|
|
162
|
+
(target.tagName==='INPUT'||target.tagName==='TEXTAREA'||target.tagName==='SELECT'||target.isContentEditable);
|
|
163
|
+
}
|
|
164
|
+
function press1(selector){
|
|
165
|
+
var el=document.querySelector(selector);
|
|
166
|
+
if(el&&!el.disabled){ el.click(); return true; }
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
function handleKey(key,stage){
|
|
170
|
+
if(key==='j'||key==='ArrowRight') return press1('[data-snap-next]');
|
|
171
|
+
if(key==='k'||key==='ArrowLeft') return press1('[data-snap-prev]');
|
|
172
|
+
if(key==='a'||key==='A') return press1('[data-approve]');
|
|
173
|
+
if(key==='r'||key==='R') return press1('[data-reject]');
|
|
174
|
+
if(key>='1'&&key<='5'){ setMode(stage,MODES[Number(key)-1],true); return true; }
|
|
175
|
+
if(key==='f'){ nextZoom(stage); return true; }
|
|
176
|
+
if(key==='+'||key==='='){ stepZoom(stage,1); return true; }
|
|
177
|
+
if(key==='-'||key==='_'){ stepZoom(stage,-1); return true; }
|
|
178
|
+
if(key==='t'){
|
|
179
|
+
if(stage.getAttribute('data-view')==='flip'){ flip(stage); } else { setMode(stage,'flip',true); }
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
if(key==='?'){ openShortcuts(); return true; }
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
document.addEventListener('keydown',function(e){
|
|
186
|
+
if(e.metaKey||e.ctrlKey||e.altKey||typing(e.target)) return;
|
|
187
|
+
var stage=document.querySelector('[data-compare]');
|
|
188
|
+
if(!stage||document.querySelector('dialog[open]')) return;
|
|
189
|
+
if(handleKey(e.key,stage)){ e.preventDefault(); }
|
|
190
|
+
});`;
|
|
191
|
+
}
|
|
192
|
+
//#endregion
|
|
193
|
+
export { reviewScript };
|
|
194
|
+
|
|
195
|
+
//# sourceMappingURL=review-script.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"review-script.mjs","names":[],"sources":["../../../src/ui/shell/review-script.ts"],"sourcesContent":["/**\n * Review workspace behavior: comparison modes, zoom, synchronized scrolling,\n * list filters, keyboard review, and the shortcuts dialog. Runs inside the\n * shell's once-only closure (`inits`, `shell` are in scope).\n */\nexport function reviewScript(): string {\n return [stateHelpers(), stageSetup(), wipeDrag(), clickControls(), keyboardShortcuts()].join(\n \"\\n\",\n );\n}\n\n/** Mode/zoom state on the stage root, remembered across snapshots. */\nfunction stateHelpers(): string {\n return `\n var MODES=['split','swipe','onion','diff','flip'];\n var ZOOMS=['fit','100','200','400'];\n function remember(key,value){ try{ localStorage.setItem(key,value); }catch(_){} }\n function recall(key){ try{ return localStorage.getItem(key); }catch(_){ return null; } }\n function press(group,value){\n if(!group) return;\n group.querySelectorAll('[data-view-value]').forEach(function(b){\n b.setAttribute('aria-pressed', b.getAttribute('data-view-value')===value ? 'true' : 'false');\n });\n }\n function setMode(stage,mode,persist){\n stage.setAttribute('data-view',mode);\n press(stage.querySelector('[data-compare-mode]'),mode);\n if(persist) remember('ss_compare_mode',mode);\n }\n function setZoom(stage,zoom,persist){\n stage.setAttribute('data-zoom',zoom);\n press(stage.querySelector('[data-compare-zoom]'),zoom);\n if(persist) remember('ss_compare_zoom',zoom);\n }\n function flip(stage){\n stage.setAttribute('data-flip', stage.getAttribute('data-flip')==='baseline' ? 'current' : 'baseline');\n }\n function nextZoom(stage){\n var cur=stage.getAttribute('data-zoom')||'fit';\n setZoom(stage,ZOOMS[(ZOOMS.indexOf(cur)+1)%ZOOMS.length],true);\n }\n function stepZoom(stage,delta){\n var cur=ZOOMS.indexOf(stage.getAttribute('data-zoom')||'fit');\n var next=Math.max(0,Math.min(ZOOMS.length-1,cur+delta));\n setZoom(stage,ZOOMS[next],true);\n }`;\n}\n\n/** Per-render wiring of a freshly swapped-in comparison stage. */\nfunction stageSetup(): string {\n return `\n function syncScroll(stage){\n var panes=Array.from(stage.querySelectorAll('.stage__pane'));\n var lock=false;\n panes.forEach(function(pane){\n pane.addEventListener('scroll',function(){\n if(lock) return;\n lock=true;\n panes.forEach(function(other){\n if(other!==pane){ other.scrollTop=pane.scrollTop; other.scrollLeft=pane.scrollLeft; }\n });\n requestAnimationFrame(function(){ lock=false; });\n });\n });\n }\n function bindRange(stage,selector,prop,scale){\n var input=stage.querySelector(selector);\n if(!input) return;\n input.addEventListener('input',function(){ stage.style.setProperty(prop,String(input.value*scale)+(scale===1?'%':'')); });\n }\n function initStage(stage){\n if(stage.getAttribute('data-ready')) return;\n stage.setAttribute('data-ready','1');\n var mode=recall('ss_compare_mode');\n if(MODES.indexOf(mode)>-1) setMode(stage,mode,false);\n var zoom=recall('ss_compare_zoom');\n if(ZOOMS.indexOf(zoom)>-1) setZoom(stage,zoom,false);\n bindRange(stage,'[data-compare-swipe]','--swipe',1);\n bindRange(stage,'[data-compare-onion]','--onion',0.01);\n var grid=stage.querySelector('.stage__grid');\n if(grid){ grid.addEventListener('click',function(){ if(stage.getAttribute('data-view')==='flip'){ flip(stage); } }); }\n syncScroll(stage);\n }\n inits.push(function(){ document.querySelectorAll('[data-compare]').forEach(initStage); });`;\n}\n\n/**\n * Drag the wipe handle to move the swipe divider. Uses pointer capture so the\n * drag keeps working when the pointer leaves the handle, and mirrors the value\n * into the (accessible) range slider.\n */\nfunction wipeDrag(): string {\n return `\n function wipePercent(stage,clientX){\n var pane=stage.querySelector('.stage__pane[data-pane=\"current\"]');\n if(!pane) return 50;\n var rect=pane.getBoundingClientRect();\n return Math.max(0,Math.min(100,(clientX-rect.left)/rect.width*100));\n }\n function setWipe(stage,percent){\n stage.style.setProperty('--swipe',percent+'%');\n var range=stage.querySelector('[data-compare-swipe]');\n if(range){ range.value=String(Math.round(percent)); }\n }\n function bindKnob(stage){\n var knob=stage.querySelector('[data-compare-knob]');\n if(!knob||knob.getAttribute('data-bound')) return;\n knob.setAttribute('data-bound','1');\n knob.addEventListener('pointerdown',function(e){\n e.preventDefault();\n knob.setPointerCapture(e.pointerId);\n setWipe(stage,wipePercent(stage,e.clientX));\n function move(ev){ setWipe(stage,wipePercent(stage,ev.clientX)); }\n function stop(){\n knob.removeEventListener('pointermove',move);\n knob.removeEventListener('pointerup',stop);\n knob.removeEventListener('pointercancel',stop);\n }\n knob.addEventListener('pointermove',move);\n knob.addEventListener('pointerup',stop);\n knob.addEventListener('pointercancel',stop);\n });\n }\n inits.push(function(){ document.querySelectorAll('[data-compare]').forEach(bindKnob); });`;\n}\n\n/** Delegated clicks: mode/zoom/filter segments and the shortcuts button. */\nfunction clickControls(): string {\n return `\n function openShortcuts(){\n var dialog=document.querySelector('dialog[data-shortcuts]');\n if(dialog&&typeof dialog.showModal==='function'&&!dialog.open){ dialog.showModal(); }\n }\n function chooseSegment(btn){\n var value=btn.getAttribute('data-view-value');\n var stage=btn.closest('[data-compare]');\n if(stage&&btn.closest('[data-compare-mode]')){ setMode(stage,value,true); }\n else if(stage&&btn.closest('[data-compare-zoom]')){ setZoom(stage,value,true); }\n else if(btn.closest('[data-filter-switch]')){\n var list=document.querySelector('[data-diff-nav]');\n if(list){ list.setAttribute('data-filter',value); }\n press(btn.closest('[data-filter-switch]'),value);\n }\n }\n document.addEventListener('click',function(e){\n var t=e.target instanceof Element ? e.target : null;\n if(!t) return;\n var btn=t.closest('[data-view-value]');\n if(btn){ chooseSegment(btn); }\n if(t.closest('[data-shortcuts-open]')){ openShortcuts(); }\n });`;\n}\n\n/** Global review shortcuts; ignored while typing or with modifier keys held. */\nfunction keyboardShortcuts(): string {\n return `\n function typing(target){\n return target instanceof HTMLElement &&\n (target.tagName==='INPUT'||target.tagName==='TEXTAREA'||target.tagName==='SELECT'||target.isContentEditable);\n }\n function press1(selector){\n var el=document.querySelector(selector);\n if(el&&!el.disabled){ el.click(); return true; }\n return false;\n }\n function handleKey(key,stage){\n if(key==='j'||key==='ArrowRight') return press1('[data-snap-next]');\n if(key==='k'||key==='ArrowLeft') return press1('[data-snap-prev]');\n if(key==='a'||key==='A') return press1('[data-approve]');\n if(key==='r'||key==='R') return press1('[data-reject]');\n if(key>='1'&&key<='5'){ setMode(stage,MODES[Number(key)-1],true); return true; }\n if(key==='f'){ nextZoom(stage); return true; }\n if(key==='+'||key==='='){ stepZoom(stage,1); return true; }\n if(key==='-'||key==='_'){ stepZoom(stage,-1); return true; }\n if(key==='t'){\n if(stage.getAttribute('data-view')==='flip'){ flip(stage); } else { setMode(stage,'flip',true); }\n return true;\n }\n if(key==='?'){ openShortcuts(); return true; }\n return false;\n }\n document.addEventListener('keydown',function(e){\n if(e.metaKey||e.ctrlKey||e.altKey||typing(e.target)) return;\n var stage=document.querySelector('[data-compare]');\n if(!stage||document.querySelector('dialog[open]')) return;\n if(handleKey(e.key,stage)){ e.preventDefault(); }\n });`;\n}\n"],"mappings":";;;;;;;;;AAKA,SAAgB,eAAuB;CACrC,OAAO;EAAC,aAAa;EAAG,WAAW;EAAG,SAAS;EAAG,cAAc;EAAG,kBAAkB;CAAC,CAAC,CAAC,KACtF,IACF;AACF;;AAGA,SAAS,eAAuB;CAC9B,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAiCT;;AAGA,SAAS,aAAqB;CAC5B,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkCT;;;;;;AAOA,SAAS,WAAmB;CAC1B,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgCT;;AAGA,SAAS,gBAAwB;CAC/B,OAAO;;;;;;;;;;;;;;;;;;;;;;;AAuBT;;AAGA,SAAS,oBAA4B;CACnC,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgCT"}
|