signalk-maintenance-tracker 1.0.0

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.
Files changed (105) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/LICENSE +201 -0
  3. package/README.md +75 -0
  4. package/dist/api/router.js +193 -0
  5. package/dist/api/router.js.map +1 -0
  6. package/dist/auth.js +22 -0
  7. package/dist/auth.js.map +1 -0
  8. package/dist/config.js +50 -0
  9. package/dist/config.js.map +1 -0
  10. package/dist/db/database.js +47 -0
  11. package/dist/db/database.js.map +1 -0
  12. package/dist/db/logs.repo.js +91 -0
  13. package/dist/db/logs.repo.js.map +1 -0
  14. package/dist/db/migrations.js +57 -0
  15. package/dist/db/migrations.js.map +1 -0
  16. package/dist/db/tags.repo.js +78 -0
  17. package/dist/db/tags.repo.js.map +1 -0
  18. package/dist/db/tasks.repo.js +78 -0
  19. package/dist/db/tasks.repo.js.map +1 -0
  20. package/dist/domain/slug.js +27 -0
  21. package/dist/domain/slug.js.map +1 -0
  22. package/dist/domain/status.js +110 -0
  23. package/dist/domain/status.js.map +1 -0
  24. package/dist/index.js +130 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/service.js +370 -0
  27. package/dist/service.js.map +1 -0
  28. package/dist/signalk/notifications.js +115 -0
  29. package/dist/signalk/notifications.js.map +1 -0
  30. package/dist/signalk/runtime.js +103 -0
  31. package/dist/signalk/runtime.js.map +1 -0
  32. package/dist/types.js +11 -0
  33. package/dist/types.js.map +1 -0
  34. package/docs/screenshots/completed.png +0 -0
  35. package/docs/screenshots/dark-mode.png +0 -0
  36. package/docs/screenshots/edit-task.png +0 -0
  37. package/docs/screenshots/homepage.png +0 -0
  38. package/docs/screenshots/main-log.png +0 -0
  39. package/docs/screenshots/task-detail.png +0 -0
  40. package/package.json +82 -0
  41. package/public/app/api/client.js +75 -0
  42. package/public/app/api/hooks.js +159 -0
  43. package/public/app/api/resource.js +156 -0
  44. package/public/app/api/signalkPaths.js +66 -0
  45. package/public/app/app.js +71 -0
  46. package/public/app/auth/auth.js +158 -0
  47. package/public/app/components/AuthControl.js +22 -0
  48. package/public/app/components/ConfirmModal.js +55 -0
  49. package/public/app/components/LogEntryModal.js +150 -0
  50. package/public/app/components/LoginModal.js +86 -0
  51. package/public/app/components/MarkdownView.js +17 -0
  52. package/public/app/components/Modal.js +89 -0
  53. package/public/app/components/Pagination.js +35 -0
  54. package/public/app/components/PathPicker.js +66 -0
  55. package/public/app/components/ProgressBar.js +26 -0
  56. package/public/app/components/StatusBadge.js +9 -0
  57. package/public/app/components/Table.js +99 -0
  58. package/public/app/components/TagInput.js +93 -0
  59. package/public/app/components/TaskFormModal.js +310 -0
  60. package/public/app/components/ThemeToggle.js +17 -0
  61. package/public/app/components/Toaster.js +24 -0
  62. package/public/app/lib/format.js +90 -0
  63. package/public/app/lib/html.js +6 -0
  64. package/public/app/lib/router.js +105 -0
  65. package/public/app/lib/sanitize.js +64 -0
  66. package/public/app/lib/slug.js +16 -0
  67. package/public/app/lib/theme.js +52 -0
  68. package/public/app/lib/toasts.js +33 -0
  69. package/public/app/lib/useListParams.js +28 -0
  70. package/public/app/main.js +20 -0
  71. package/public/app/pages/MasterLogPage.js +279 -0
  72. package/public/app/pages/TaskDetailPage.js +294 -0
  73. package/public/app/pages/TaskListPage.js +279 -0
  74. package/public/app/types.js +90 -0
  75. package/public/icons/apple-touch-icon.png +0 -0
  76. package/public/icons/favicon-16.png +0 -0
  77. package/public/icons/favicon-32.png +0 -0
  78. package/public/icons/favicon.ico +0 -0
  79. package/public/icons/icon-192.png +0 -0
  80. package/public/icons/icon-512.png +0 -0
  81. package/public/index.html +82 -0
  82. package/public/manifest.webmanifest +37 -0
  83. package/public/styles/main.css +787 -0
  84. package/public/styles/tokens.css +55 -0
  85. package/public/vendor/VERSIONS.json +9 -0
  86. package/public/vendor/bootstrap-icons/bootstrap-icons.css +2106 -0
  87. package/public/vendor/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
  88. package/public/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
  89. package/public/vendor/dayjs/constant.js +25 -0
  90. package/public/vendor/dayjs/index.d.ts +1 -0
  91. package/public/vendor/dayjs/index.js +541 -0
  92. package/public/vendor/dayjs/locale/en.js +12 -0
  93. package/public/vendor/dayjs/utils.js +58 -0
  94. package/public/vendor/htm.d.ts +1 -0
  95. package/public/vendor/htm.js +1 -0
  96. package/public/vendor/preact-hooks.d.ts +1 -0
  97. package/public/vendor/preact-hooks.js +2 -0
  98. package/public/vendor/preact.d.ts +1 -0
  99. package/public/vendor/preact.js +2 -0
  100. package/public/vendor/signals-core.d.ts +1 -0
  101. package/public/vendor/signals-core.js +1 -0
  102. package/public/vendor/signals.d.ts +1 -0
  103. package/public/vendor/signals.js +1 -0
  104. package/public/vendor/snarkdown.d.ts +1 -0
  105. package/public/vendor/snarkdown.js +2 -0
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Authentication against SignalK's own endpoints (§7.7, §8.6). Module-level
3
+ * signals hold the session state; the plugin API rides on the same-origin
4
+ * session cookie, so there is no token handling here.
5
+ */
6
+ import { signal } from '../../vendor/signals.js';
7
+ import { toast } from '../lib/toasts.js';
8
+ import { invalidateAll } from '../api/resource.js';
9
+
10
+ /** @typedef {{ checked: boolean, isLoggedIn: boolean, username: string|null }} AuthState */
11
+
12
+ /** @type {import('../../vendor/signals.js').Signal<AuthState>} */
13
+ export const authState = signal({
14
+ checked: false,
15
+ isLoggedIn: false,
16
+ username: null,
17
+ });
18
+
19
+ export const loginModalOpen = signal(false);
20
+
21
+ /** @type {ReturnType<typeof setTimeout>|null} */
22
+ let renewTimer = null;
23
+ let unauthorizedNotified = false;
24
+
25
+ export function openLoginModal() {
26
+ loginModalOpen.value = true;
27
+ }
28
+
29
+ export function closeLoginModal() {
30
+ loginModalOpen.value = false;
31
+ unauthorizedNotified = false;
32
+ }
33
+
34
+ /**
35
+ * Establish/refresh session state from GET /skServer/loginStatus (§8.6).
36
+ */
37
+ export async function refreshLoginStatus() {
38
+ try {
39
+ const res = await fetch('/skServer/loginStatus', {
40
+ credentials: 'same-origin',
41
+ });
42
+ if (!res.ok) throw new Error('loginStatus returned ' + res.status);
43
+ const body = await res.json();
44
+ const isLoggedIn = !!body && body.status === 'loggedIn';
45
+ authState.value = {
46
+ checked: true,
47
+ isLoggedIn: isLoggedIn,
48
+ username:
49
+ isLoggedIn && typeof body.username === 'string' ? body.username : null,
50
+ };
51
+ } catch {
52
+ // Can't reach the server or no security configured — treat as logged out.
53
+ authState.value = { checked: true, isLoggedIn: false, username: null };
54
+ }
55
+ }
56
+
57
+ /**
58
+ * POST /signalk/v1/auth/login. Resolves on success; throws with a
59
+ * user-displayable message on failure (401 → invalid credentials).
60
+ * @param {string} username
61
+ * @param {string} password
62
+ * @param {boolean} [rememberMe]
63
+ */
64
+ export async function login(username, password, rememberMe) {
65
+ const res = await fetch('/signalk/v1/auth/login', {
66
+ method: 'POST',
67
+ credentials: 'same-origin',
68
+ headers: { 'Content-Type': 'application/json' },
69
+ body: JSON.stringify({
70
+ username: username,
71
+ password: password,
72
+ rememberMe: !!rememberMe,
73
+ }),
74
+ });
75
+ if (!res.ok) {
76
+ throw new Error(
77
+ res.status === 401
78
+ ? 'Invalid username or password'
79
+ : 'Login failed (' + res.status + ')',
80
+ );
81
+ }
82
+ /** @type {{ token?: string, timeToLive?: number }|null} */
83
+ let body;
84
+ try {
85
+ body = await res.json();
86
+ } catch {
87
+ body = null;
88
+ }
89
+ authState.value = { checked: true, isLoggedIn: true, username: username };
90
+ unauthorizedNotified = false;
91
+ scheduleRenewal(
92
+ body && typeof body.timeToLive === 'number' ? body.timeToLive : null,
93
+ );
94
+ // Data fetched while logged out may have 401'd — refetch everything live.
95
+ invalidateAll();
96
+ }
97
+
98
+ /** PUT /signalk/v1/auth/logout; local state is cleared regardless of outcome. */
99
+ export async function logout() {
100
+ try {
101
+ await fetch('/signalk/v1/auth/logout', {
102
+ method: 'PUT',
103
+ credentials: 'same-origin',
104
+ });
105
+ } catch {
106
+ // ignore — we still drop the local session
107
+ }
108
+ if (renewTimer) {
109
+ clearTimeout(renewTimer);
110
+ renewTimer = null;
111
+ }
112
+ authState.value = { checked: true, isLoggedIn: false, username: null };
113
+ }
114
+
115
+ /**
116
+ * Re-validate before the token's timeToLive elapses (§7.7).
117
+ * @param {number|null} timeToLiveSeconds
118
+ */
119
+ function scheduleRenewal(timeToLiveSeconds) {
120
+ if (renewTimer) clearTimeout(renewTimer);
121
+ renewTimer = null;
122
+ if (!timeToLiveSeconds || timeToLiveSeconds <= 0) return;
123
+ const delayMs = Math.max(timeToLiveSeconds * 1000 * 0.8, 10000);
124
+ renewTimer = setTimeout(function () {
125
+ renewTimer = null;
126
+ refreshLoginStatus().then(function () {
127
+ if (authState.value.isLoggedIn) scheduleRenewal(timeToLiveSeconds);
128
+ });
129
+ }, delayMs);
130
+ }
131
+
132
+ /**
133
+ * Called by the API client on any 401/403 (§7.6): flip to logged-out and
134
+ * prompt re-login (once, not per failing poll).
135
+ */
136
+ export function onUnauthorized() {
137
+ authState.value = { checked: true, isLoggedIn: false, username: null };
138
+ if (unauthorizedNotified || loginModalOpen.value) return;
139
+ unauthorizedNotified = true;
140
+ toast('Please log in to load maintenance data.', 'error');
141
+ openLoginModal();
142
+ }
143
+
144
+ /**
145
+ * Hook façade over the auth signals (§7.7). Reading authState.value inside a
146
+ * component render subscribes it via @preact/signals.
147
+ */
148
+ export function useAuth() {
149
+ const state = authState.value;
150
+ return {
151
+ checked: state.checked,
152
+ isLoggedIn: state.isLoggedIn,
153
+ username: state.username,
154
+ login: login,
155
+ logout: logout,
156
+ openLoginModal: openLoginModal,
157
+ };
158
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Footer auth control (§7.7): a plain "Log in" link when anonymous,
3
+ * "Log out" when authenticated.
4
+ */
5
+ import { html } from '../lib/html.js';
6
+ import { useAuth } from '../auth/auth.js';
7
+
8
+ export function AuthControl() {
9
+ const auth = useAuth();
10
+ if (!auth.isLoggedIn) {
11
+ return html`
12
+ <button type="button" class="btn-link" onClick=${auth.openLoginModal}>
13
+ Log in
14
+ </button>
15
+ `;
16
+ }
17
+ return html`
18
+ <button type="button" class="btn-link" onClick=${() => auth.logout()}>
19
+ Log out
20
+ </button>
21
+ `;
22
+ }
@@ -0,0 +1,55 @@
1
+ import { html } from '../lib/html.js';
2
+ import { useState } from '../../vendor/preact-hooks.js';
3
+ import { Modal } from './Modal.js';
4
+
5
+ /**
6
+ * Simple confirmation modal (§7.5, delete flows).
7
+ * @param {{
8
+ * title: string,
9
+ * message: any,
10
+ * confirmLabel?: string,
11
+ * onConfirm: () => Promise<void>|void,
12
+ * onClose: () => void,
13
+ * }} props
14
+ */
15
+ export function ConfirmModal(props) {
16
+ const [busy, setBusy] = useState(false);
17
+ const [error, setError] = useState('');
18
+
19
+ const confirm = async () => {
20
+ setBusy(true);
21
+ setError('');
22
+ try {
23
+ await props.onConfirm();
24
+ } catch (err) {
25
+ setError(err instanceof Error ? err.message : 'Operation failed');
26
+ setBusy(false);
27
+ }
28
+ };
29
+
30
+ const footer = html`
31
+ <button type="button" class="btn" onClick=${props.onClose} disabled=${busy}>
32
+ Cancel
33
+ </button>
34
+ <button
35
+ type="button"
36
+ class="btn btn-danger"
37
+ onClick=${confirm}
38
+ disabled=${busy}
39
+ >
40
+ ${busy ? 'Working…' : props.confirmLabel || 'Delete'}
41
+ </button>
42
+ `;
43
+
44
+ return html`
45
+ <${Modal}
46
+ title=${props.title}
47
+ narrow
48
+ onClose=${props.onClose}
49
+ footer=${footer}
50
+ >
51
+ ${error ? html`<div class="form-error">${error}</div>` : null}
52
+ <p style="margin:0">${props.message}</p>
53
+ <//>
54
+ `;
55
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Log-entry modal (§7.5): "Mark complete" (creates a log entry for a task)
3
+ * and editing an existing entry share this form. On create the runtime hours
4
+ * are prefilled from the task's current_runtime — which comes from the plugin
5
+ * /tasks API, never from SignalK directly (§8.4).
6
+ */
7
+ import { html } from '../lib/html.js';
8
+ import { useState } from '../../vendor/preact-hooks.js';
9
+ import { Modal } from './Modal.js';
10
+ import { addLog, updateLog } from '../api/hooks.js';
11
+ import { toDateInput } from '../lib/format.js';
12
+ import { toast } from '../lib/toasts.js';
13
+
14
+ /** @typedef {import('../types.js').TaskDTO} TaskDTO */
15
+ /** @typedef {import('../types.js').LogDTO} LogDTO */
16
+
17
+ /**
18
+ * Exactly one of `task` (mark complete) or `entry` (edit) drives the mode.
19
+ * @param {{ task?: TaskDTO|null, entry?: LogDTO|null, onClose: () => void }} props
20
+ */
21
+ export function LogEntryModal(props) {
22
+ const entry = props.entry || null;
23
+ const task = props.task || null;
24
+ const isEdit = !!entry;
25
+
26
+ const initialRuntime = isEdit
27
+ ? entry && entry.runtime_hours !== null
28
+ ? String(entry.runtime_hours)
29
+ : ''
30
+ : task &&
31
+ task.current_runtime !== null &&
32
+ task.current_runtime !== undefined
33
+ ? String(Math.round(task.current_runtime * 10) / 10)
34
+ : '';
35
+
36
+ const [date, setDate] = useState(
37
+ isEdit && entry ? toDateInput(entry.maintenance_date) : toDateInput(),
38
+ );
39
+ const [runtime, setRuntime] = useState(initialRuntime);
40
+ const [notes, setNotes] = useState(
41
+ isEdit && entry && entry.notes ? entry.notes : '',
42
+ );
43
+ const [error, setError] = useState('');
44
+ const [busy, setBusy] = useState(false);
45
+
46
+ /** @param {Event} e */
47
+ const onSubmit = async (e) => {
48
+ e.preventDefault();
49
+ setError('');
50
+ if (!date) {
51
+ setError('Maintenance date is required.');
52
+ return;
53
+ }
54
+ /** @type {import('../types.js').LogInput} */
55
+ const input = {
56
+ maintenance_date: date,
57
+ notes: notes.trim() ? notes : null,
58
+ runtime_hours: null,
59
+ };
60
+ if (runtime.trim() !== '') {
61
+ const hours = Number(runtime);
62
+ if (!isFinite(hours) || hours < 0) {
63
+ setError('Runtime hours must be a non-negative number.');
64
+ return;
65
+ }
66
+ input.runtime_hours = hours;
67
+ }
68
+ setBusy(true);
69
+ try {
70
+ if (isEdit && entry) {
71
+ await updateLog(entry.id, input);
72
+ toast('Log entry updated.', 'success');
73
+ } else if (task) {
74
+ await addLog(task.slug, input);
75
+ toast('Marked "' + task.name + '" complete.', 'success');
76
+ }
77
+ props.onClose();
78
+ } catch (err) {
79
+ setError(err instanceof Error ? err.message : 'Save failed');
80
+ setBusy(false);
81
+ }
82
+ };
83
+
84
+ const footer = html`
85
+ <button type="button" class="btn" onClick=${props.onClose} disabled=${busy}>
86
+ Cancel
87
+ </button>
88
+ <button
89
+ type="submit"
90
+ form="log-form"
91
+ class="btn btn-primary"
92
+ disabled=${busy}
93
+ >
94
+ ${busy ? 'Saving…' : isEdit ? 'Save changes' : 'Mark complete'}
95
+ </button>
96
+ `;
97
+
98
+ const title = isEdit
99
+ ? 'Edit log entry'
100
+ : 'Mark complete' + (task ? ' — ' + task.name : '');
101
+
102
+ return html`
103
+ <${Modal} title=${title} onClose=${props.onClose} footer=${footer}>
104
+ <form id="log-form" onSubmit=${onSubmit}>
105
+ ${error ? html`<div class="form-error">${error}</div>` : null}
106
+
107
+ <div class="field">
108
+ <label class="field-label" for="log-date">Maintenance date</label>
109
+ <input
110
+ id="log-date"
111
+ class="input"
112
+ type="date"
113
+ value=${date}
114
+ onInput=${(/** @type {any} */ e) => setDate(e.currentTarget.value)}
115
+ />
116
+ </div>
117
+
118
+ <div class="field">
119
+ <label class="field-label" for="log-runtime">Runtime hours</label>
120
+ <input
121
+ id="log-runtime"
122
+ class="input"
123
+ type="number"
124
+ min="0"
125
+ step="any"
126
+ value=${runtime}
127
+ onInput=${(/** @type {any} */ e) => setRuntime(e.currentTarget.value)}
128
+ />
129
+ ${
130
+ !isEdit
131
+ ? html`<div class="field-hint">
132
+ Prefilled with the current runtime reading when available.
133
+ </div>`
134
+ : null
135
+ }
136
+ </div>
137
+
138
+ <div class="field">
139
+ <label class="field-label" for="log-notes">Notes (markdown)</label>
140
+ <textarea
141
+ id="log-notes"
142
+ class="textarea"
143
+ value=${notes}
144
+ onInput=${(/** @type {any} */ e) => setNotes(e.currentTarget.value)}
145
+ />
146
+ </div>
147
+ </form>
148
+ <//>
149
+ `;
150
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Login modal (§7.7): built on the shared Modal, opened from AuthControl or
3
+ * automatically on a 401. Always rendered by the shell; gates itself on the
4
+ * loginModalOpen signal.
5
+ */
6
+ import { html } from '../lib/html.js';
7
+ import { useState } from '../../vendor/preact-hooks.js';
8
+ import { Modal } from './Modal.js';
9
+ import { login, loginModalOpen, closeLoginModal } from '../auth/auth.js';
10
+ import { toast } from '../lib/toasts.js';
11
+
12
+ export function LoginModal() {
13
+ if (!loginModalOpen.value) return null;
14
+ return html`<${LoginForm} />`;
15
+ }
16
+
17
+ function LoginForm() {
18
+ const [username, setUsername] = useState('');
19
+ const [password, setPassword] = useState('');
20
+ const [rememberMe, setRememberMe] = useState(false);
21
+ const [error, setError] = useState('');
22
+ const [busy, setBusy] = useState(false);
23
+
24
+ /** @param {Event} e */
25
+ const onSubmit = async (e) => {
26
+ e.preventDefault();
27
+ setError('');
28
+ setBusy(true);
29
+ try {
30
+ await login(username, password, rememberMe);
31
+ closeLoginModal();
32
+ toast('Logged in as ' + username, 'success');
33
+ } catch (err) {
34
+ setError(err instanceof Error ? err.message : 'Login failed');
35
+ } finally {
36
+ setBusy(false);
37
+ }
38
+ };
39
+
40
+ return html`
41
+ <${Modal} title="Log in" narrow onClose=${closeLoginModal}>
42
+ <form onSubmit=${onSubmit}>
43
+ ${error ? html`<div class="form-error">${error}</div>` : null}
44
+ <div class="field">
45
+ <label class="field-label" for="login-username">Username</label>
46
+ <input
47
+ id="login-username"
48
+ class="input"
49
+ autocomplete="username"
50
+ value=${username}
51
+ onInput=${(/** @type {any} */ e) => setUsername(e.currentTarget.value)}
52
+ />
53
+ </div>
54
+ <div class="field">
55
+ <label class="field-label" for="login-password">Password</label>
56
+ <input
57
+ id="login-password"
58
+ class="input"
59
+ type="password"
60
+ autocomplete="current-password"
61
+ value=${password}
62
+ onInput=${(/** @type {any} */ e) => setPassword(e.currentTarget.value)}
63
+ />
64
+ </div>
65
+ <div class="field">
66
+ <label class="field-label" for="login-remember">
67
+ <input
68
+ id="login-remember"
69
+ type="checkbox"
70
+ checked=${rememberMe}
71
+ onInput=${(/** @type {any} */ e) => setRememberMe(e.currentTarget.checked)}
72
+ />
73
+ ${' '}Remember me
74
+ </label>
75
+ </div>
76
+ <button
77
+ type="submit"
78
+ class="btn btn-primary"
79
+ disabled=${busy || !username}
80
+ >
81
+ ${busy ? 'Logging in…' : 'Log in'}
82
+ </button>
83
+ </form>
84
+ <//>
85
+ `;
86
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The single place raw HTML is set (§7.8): markdown → snarkdown → sanitize →
3
+ * dangerouslySetInnerHTML. Nothing else may set raw HTML.
4
+ */
5
+ import snarkdown from '../../vendor/snarkdown.js';
6
+ import { html } from '../lib/html.js';
7
+ import { sanitizeHtml } from '../lib/sanitize.js';
8
+
9
+ /** @param {{ markdown: string|null|undefined }} props */
10
+ export function MarkdownView(props) {
11
+ if (!props.markdown) return null;
12
+ const clean = sanitizeHtml(snarkdown(props.markdown));
13
+ return html`<div
14
+ class="markdown"
15
+ dangerouslySetInnerHTML=${{ __html: clean }}
16
+ />`;
17
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Shared modal primitive (§7.5): overlay, Escape-to-close, focus trap,
3
+ * role="dialog". Every modal in the app builds on this.
4
+ */
5
+ import { html } from '../lib/html.js';
6
+ import { useEffect, useRef } from '../../vendor/preact-hooks.js';
7
+
8
+ const FOCUSABLE =
9
+ 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled])';
10
+
11
+ /**
12
+ * @param {{ title: string, onClose: () => void, children?: any, footer?: any, narrow?: boolean }} props
13
+ */
14
+ export function Modal(props) {
15
+ /** @type {{ current: HTMLElement|null }} */
16
+ const ref = useRef(null);
17
+
18
+ useEffect(() => {
19
+ /** @param {KeyboardEvent} e */
20
+ const onKey = (e) => {
21
+ if (e.key === 'Escape') props.onClose();
22
+ };
23
+ document.addEventListener('keydown', onKey);
24
+ const node = ref.current;
25
+ if (node) {
26
+ const first = node.querySelector(FOCUSABLE);
27
+ if (first && first instanceof HTMLElement) first.focus();
28
+ }
29
+ return () => document.removeEventListener('keydown', onKey);
30
+ }, []);
31
+
32
+ /** @param {KeyboardEvent} e */
33
+ const trapTab = (e) => {
34
+ if (e.key !== 'Tab') return;
35
+ const node = ref.current;
36
+ if (!node) return;
37
+ const focusables = node.querySelectorAll(FOCUSABLE);
38
+ if (!focusables.length) return;
39
+ const first = focusables[0];
40
+ const last = focusables[focusables.length - 1];
41
+ if (
42
+ e.shiftKey &&
43
+ document.activeElement === first &&
44
+ last instanceof HTMLElement
45
+ ) {
46
+ e.preventDefault();
47
+ last.focus();
48
+ } else if (
49
+ !e.shiftKey &&
50
+ document.activeElement === last &&
51
+ first instanceof HTMLElement
52
+ ) {
53
+ e.preventDefault();
54
+ first.focus();
55
+ }
56
+ };
57
+
58
+ /** @param {MouseEvent} e */
59
+ const onOverlayDown = (e) => {
60
+ if (e.target === e.currentTarget) props.onClose();
61
+ };
62
+
63
+ return html`
64
+ <div class="modal-overlay" onMouseDown=${onOverlayDown}>
65
+ <div
66
+ class=${'modal' + (props.narrow ? ' narrow' : '')}
67
+ role="dialog"
68
+ aria-modal="true"
69
+ aria-label=${props.title}
70
+ ref=${ref}
71
+ onKeyDown=${trapTab}
72
+ >
73
+ <div class="modal-header">
74
+ <h2 class="modal-title">${props.title}</h2>
75
+ <button
76
+ type="button"
77
+ class="btn-icon"
78
+ aria-label="Close"
79
+ onClick=${props.onClose}
80
+ >
81
+ <i class="bi bi-x-lg" />
82
+ </button>
83
+ </div>
84
+ <div class="modal-body">${props.children}</div>
85
+ ${props.footer ? html`<div class="modal-footer">${props.footer}</div>` : null}
86
+ </div>
87
+ </div>
88
+ `;
89
+ }
@@ -0,0 +1,35 @@
1
+ import { html } from '../lib/html.js';
2
+
3
+ /**
4
+ * @param {{ page: number, pageSize: number, total: number, onPage: (page: number) => void }} props
5
+ */
6
+ export function Pagination(props) {
7
+ const pages = Math.max(1, Math.ceil(props.total / props.pageSize));
8
+ if (props.total === 0) return null;
9
+ const first = (props.page - 1) * props.pageSize + 1;
10
+ const last = Math.min(props.total, props.page * props.pageSize);
11
+ return html`
12
+ <div class="pagination">
13
+ <span>${first}–${last} of ${props.total}</span>
14
+ <button
15
+ type="button"
16
+ class="btn-icon"
17
+ aria-label="Previous page"
18
+ disabled=${props.page <= 1}
19
+ onClick=${() => props.onPage(props.page - 1)}
20
+ >
21
+ <i class="bi bi-chevron-left" />
22
+ </button>
23
+ <span>${props.page} / ${pages}</span>
24
+ <button
25
+ type="button"
26
+ class="btn-icon"
27
+ aria-label="Next page"
28
+ disabled=${props.page >= pages}
29
+ onClick=${() => props.onPage(props.page + 1)}
30
+ >
31
+ <i class="bi bi-chevron-right" />
32
+ </button>
33
+ </div>
34
+ `;
35
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Runtime-path autocomplete (§8.4): free-text input over the candidate paths
3
+ * flattened from SignalK's vessels/self snapshot. The snapshot is fetched
4
+ * once per session, lazily — this component only mounts inside the task
5
+ * editor, so read-only sessions never pay the cost.
6
+ */
7
+ import { html } from '../lib/html.js';
8
+ import { useState } from '../../vendor/preact-hooks.js';
9
+ import { useSignalKPaths } from '../api/signalkPaths.js';
10
+
11
+ /**
12
+ * @param {{ value: string, onChange: (path: string) => void }} props
13
+ */
14
+ export function PathPicker(props) {
15
+ const [open, setOpen] = useState(false);
16
+ const paths = useSignalKPaths();
17
+
18
+ const value = props.value || '';
19
+ const candidates = paths.data || [];
20
+ const matches = candidates
21
+ .filter(
22
+ (p) => !value || p.toLowerCase().indexOf(value.toLowerCase()) !== -1,
23
+ )
24
+ .slice(0, 10);
25
+
26
+ return html`
27
+ <div class="combo">
28
+ <input
29
+ class="input"
30
+ placeholder="e.g. propulsion.port.runTime"
31
+ value=${value}
32
+ onInput=${(/** @type {any} */ e) => {
33
+ props.onChange(e.currentTarget.value);
34
+ setOpen(true);
35
+ }}
36
+ onFocus=${() => setOpen(true)}
37
+ onBlur=${() => setTimeout(() => setOpen(false), 150)}
38
+ />
39
+ ${
40
+ open && matches.length
41
+ ? html`<ul class="combo-list">
42
+ ${matches.map(
43
+ (p) =>
44
+ html`<li
45
+ key=${p}
46
+ onMouseDown=${() => {
47
+ props.onChange(p);
48
+ setOpen(false);
49
+ }}
50
+ >
51
+ ${p}
52
+ </li>`,
53
+ )}
54
+ </ul>`
55
+ : null
56
+ }
57
+ ${
58
+ paths.error
59
+ ? html`<div class="field-hint">
60
+ Could not load paths from SignalK — free text still works.
61
+ </div>`
62
+ : null
63
+ }
64
+ </div>
65
+ `;
66
+ }