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.
- package/CHANGELOG.md +46 -0
- package/LICENSE +201 -0
- package/README.md +75 -0
- package/dist/api/router.js +193 -0
- package/dist/api/router.js.map +1 -0
- package/dist/auth.js +22 -0
- package/dist/auth.js.map +1 -0
- package/dist/config.js +50 -0
- package/dist/config.js.map +1 -0
- package/dist/db/database.js +47 -0
- package/dist/db/database.js.map +1 -0
- package/dist/db/logs.repo.js +91 -0
- package/dist/db/logs.repo.js.map +1 -0
- package/dist/db/migrations.js +57 -0
- package/dist/db/migrations.js.map +1 -0
- package/dist/db/tags.repo.js +78 -0
- package/dist/db/tags.repo.js.map +1 -0
- package/dist/db/tasks.repo.js +78 -0
- package/dist/db/tasks.repo.js.map +1 -0
- package/dist/domain/slug.js +27 -0
- package/dist/domain/slug.js.map +1 -0
- package/dist/domain/status.js +110 -0
- package/dist/domain/status.js.map +1 -0
- package/dist/index.js +130 -0
- package/dist/index.js.map +1 -0
- package/dist/service.js +370 -0
- package/dist/service.js.map +1 -0
- package/dist/signalk/notifications.js +115 -0
- package/dist/signalk/notifications.js.map +1 -0
- package/dist/signalk/runtime.js +103 -0
- package/dist/signalk/runtime.js.map +1 -0
- package/dist/types.js +11 -0
- package/dist/types.js.map +1 -0
- package/docs/screenshots/completed.png +0 -0
- package/docs/screenshots/dark-mode.png +0 -0
- package/docs/screenshots/edit-task.png +0 -0
- package/docs/screenshots/homepage.png +0 -0
- package/docs/screenshots/main-log.png +0 -0
- package/docs/screenshots/task-detail.png +0 -0
- package/package.json +82 -0
- package/public/app/api/client.js +75 -0
- package/public/app/api/hooks.js +159 -0
- package/public/app/api/resource.js +156 -0
- package/public/app/api/signalkPaths.js +66 -0
- package/public/app/app.js +71 -0
- package/public/app/auth/auth.js +158 -0
- package/public/app/components/AuthControl.js +22 -0
- package/public/app/components/ConfirmModal.js +55 -0
- package/public/app/components/LogEntryModal.js +150 -0
- package/public/app/components/LoginModal.js +86 -0
- package/public/app/components/MarkdownView.js +17 -0
- package/public/app/components/Modal.js +89 -0
- package/public/app/components/Pagination.js +35 -0
- package/public/app/components/PathPicker.js +66 -0
- package/public/app/components/ProgressBar.js +26 -0
- package/public/app/components/StatusBadge.js +9 -0
- package/public/app/components/Table.js +99 -0
- package/public/app/components/TagInput.js +93 -0
- package/public/app/components/TaskFormModal.js +310 -0
- package/public/app/components/ThemeToggle.js +17 -0
- package/public/app/components/Toaster.js +24 -0
- package/public/app/lib/format.js +90 -0
- package/public/app/lib/html.js +6 -0
- package/public/app/lib/router.js +105 -0
- package/public/app/lib/sanitize.js +64 -0
- package/public/app/lib/slug.js +16 -0
- package/public/app/lib/theme.js +52 -0
- package/public/app/lib/toasts.js +33 -0
- package/public/app/lib/useListParams.js +28 -0
- package/public/app/main.js +20 -0
- package/public/app/pages/MasterLogPage.js +279 -0
- package/public/app/pages/TaskDetailPage.js +294 -0
- package/public/app/pages/TaskListPage.js +279 -0
- package/public/app/types.js +90 -0
- package/public/icons/apple-touch-icon.png +0 -0
- package/public/icons/favicon-16.png +0 -0
- package/public/icons/favicon-32.png +0 -0
- package/public/icons/favicon.ico +0 -0
- package/public/icons/icon-192.png +0 -0
- package/public/icons/icon-512.png +0 -0
- package/public/index.html +82 -0
- package/public/manifest.webmanifest +37 -0
- package/public/styles/main.css +787 -0
- package/public/styles/tokens.css +55 -0
- package/public/vendor/VERSIONS.json +9 -0
- package/public/vendor/bootstrap-icons/bootstrap-icons.css +2106 -0
- package/public/vendor/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
- package/public/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
- package/public/vendor/dayjs/constant.js +25 -0
- package/public/vendor/dayjs/index.d.ts +1 -0
- package/public/vendor/dayjs/index.js +541 -0
- package/public/vendor/dayjs/locale/en.js +12 -0
- package/public/vendor/dayjs/utils.js +58 -0
- package/public/vendor/htm.d.ts +1 -0
- package/public/vendor/htm.js +1 -0
- package/public/vendor/preact-hooks.d.ts +1 -0
- package/public/vendor/preact-hooks.js +2 -0
- package/public/vendor/preact.d.ts +1 -0
- package/public/vendor/preact.js +2 -0
- package/public/vendor/signals-core.d.ts +1 -0
- package/public/vendor/signals-core.js +1 -0
- package/public/vendor/signals.d.ts +1 -0
- package/public/vendor/signals.js +1 -0
- package/public/vendor/snarkdown.d.ts +1 -0
- 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
|
+
}
|