@hotstaq/admin-panel 0.3.18 → 0.4.1

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 (43) hide show
  1. package/assets/css/freelight-panel.css +174 -0
  2. package/build/WebExport.d.ts.map +1 -1
  3. package/build/WebExport.js +7 -1
  4. package/build/WebExport.js.map +1 -1
  5. package/build/components/admin-add-panel.d.ts +56 -0
  6. package/build/components/admin-add-panel.d.ts.map +1 -0
  7. package/build/components/admin-add-panel.js +177 -0
  8. package/build/components/admin-add-panel.js.map +1 -0
  9. package/build/components/admin-card-table.d.ts +55 -0
  10. package/build/components/admin-card-table.d.ts.map +1 -0
  11. package/build/components/admin-card-table.js +149 -0
  12. package/build/components/admin-card-table.js.map +1 -0
  13. package/build/components/admin-detail-page.d.ts +68 -0
  14. package/build/components/admin-detail-page.d.ts.map +1 -0
  15. package/build/components/admin-detail-page.js +247 -0
  16. package/build/components/admin-detail-page.js.map +1 -0
  17. package/build/components/admin-disclaimer.d.ts +21 -0
  18. package/build/components/admin-disclaimer.d.ts.map +1 -0
  19. package/build/components/admin-disclaimer.js +37 -0
  20. package/build/components/admin-disclaimer.js.map +1 -0
  21. package/build/components/admin-eyebrow-heading.d.ts +23 -0
  22. package/build/components/admin-eyebrow-heading.d.ts.map +1 -0
  23. package/build/components/admin-eyebrow-heading.js +40 -0
  24. package/build/components/admin-eyebrow-heading.js.map +1 -0
  25. package/build/components/admin-form-field.d.ts +69 -0
  26. package/build/components/admin-form-field.d.ts.map +1 -0
  27. package/build/components/admin-form-field.js +117 -0
  28. package/build/components/admin-form-field.js.map +1 -0
  29. package/build/index.d.ts +7 -1
  30. package/build/index.d.ts.map +1 -1
  31. package/build/index.js +16 -1
  32. package/build/index.js.map +1 -1
  33. package/build/tsconfig.tsbuildinfo +1 -1
  34. package/build-web/AdminPanelComponents.js +2 -2
  35. package/package.json +1 -1
  36. package/src/WebExport.ts +7 -1
  37. package/src/components/admin-add-panel.ts +210 -0
  38. package/src/components/admin-card-table.ts +183 -0
  39. package/src/components/admin-detail-page.ts +270 -0
  40. package/src/components/admin-disclaimer.ts +43 -0
  41. package/src/components/admin-eyebrow-heading.ts +50 -0
  42. package/src/components/admin-form-field.ts +166 -0
  43. package/src/index.ts +18 -2
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@hotstaq/admin-panel",
3
3
  "description": "",
4
- "version": "0.3.18",
4
+ "version": "0.4.1",
5
5
  "main": "build/index.js",
6
6
  "scripts": {
7
7
  "start": "hotstaq --hotsite ./HotSite.json --env-file ./.env run --server-type web-api",
package/src/WebExport.ts CHANGED
@@ -23,7 +23,13 @@ async function buildAssets (): Promise<any>
23
23
  "AdminTable",
24
24
  "AdminTableField",
25
25
  "AdminTableRow",
26
- "AdminText"]
26
+ "AdminText",
27
+ "AdminFormField",
28
+ "AdminAddPanel",
29
+ "AdminCardTable",
30
+ "AdminDetailPage",
31
+ "AdminDisclaimer",
32
+ "AdminEyebrowHeading"]
27
33
  });
28
34
  }
29
35
 
@@ -0,0 +1,210 @@
1
+ import { HotStaq, Hot, HotAPI, HotComponent, HotComponentOutput } from "hotstaq";
2
+
3
+ /**
4
+ * Self-contained inline add form. Replaces the modal opened by
5
+ * <admin-edit hot-type="add"> with a Bootstrap-collapse card that
6
+ * lives directly on the page — header carries the "+ Add" toggle,
7
+ * body holds the form fields. No cross-component DOM injection.
8
+ *
9
+ * Place this above an <admin-card-table>; when the user clicks the
10
+ * header toggle, the form panel slides down. On Save, the paired
11
+ * table is asked to refresh via `attached_list`.
12
+ *
13
+ * Usage:
14
+ * <admin-add-panel name="bankAccountsAdd"
15
+ * hot-title="Add a bank account"
16
+ * hot-attached_list="bankAccountsList"
17
+ * hot-add_text="+ Add bank account"
18
+ * hot-button_title="Create"
19
+ * hot-onsave="<(values) => {...}Ra>">
20
+ * <admin-form-field hot-field="name" hot-label="Name" hot-required="1"
21
+ * hot-col="col-md-6"></admin-form-field>
22
+ * <admin-form-field hot-field="bankSyncAPIType" hot-label="Sync type"
23
+ * hot-control="select"
24
+ * hot-options="paypal_webhooks:PayPal Webhooks"
25
+ * hot-col="col-md-6"></admin-form-field>
26
+ * </admin-add-panel>
27
+ */
28
+ export class AdminAddPanel extends HotComponent
29
+ {
30
+ /** Title shown in the card header (next to the toggle button). */
31
+ title: string;
32
+ /** Submit button label. */
33
+ button_title: string;
34
+ /** Cancel button label. Empty hides the cancel button. */
35
+ cancel_text: string;
36
+ /** Optional id of the partner <admin-card-table>; refreshList() is called after a successful save. */
37
+ attached_list: string;
38
+ /** Text shown on the header toggle button. */
39
+ add_text: string;
40
+ /** "1" / "true" → panel starts expanded. */
41
+ start_open: string;
42
+ /** What to run when the user clicks Save. Receives a values object built from hot-field inputs. Return false to keep the panel open. */
43
+ onsave: (values: any) => Promise<boolean | void>;
44
+
45
+ protected panelId: string;
46
+ protected formId: string;
47
+
48
+ constructor (copy: HotComponent | HotStaq, api: HotAPI)
49
+ {
50
+ super (copy, api);
51
+
52
+ this.tag = "admin-add-panel";
53
+ this.title = "";
54
+ this.button_title = "Save";
55
+ this.cancel_text = "Cancel";
56
+ this.attached_list = "";
57
+ this.add_text = "+ Add";
58
+ this.start_open = "0";
59
+ this.onsave = null;
60
+ }
61
+
62
+ /**
63
+ * Wire submit + cancel handlers after the DOM is in place.
64
+ * Bootstrap's data-bs-toggle handles open/close automatically.
65
+ */
66
+ onPostPlace (parentHtmlElement: HTMLElement, htmlElement: HTMLElement): HTMLElement
67
+ {
68
+ const self = this;
69
+ const root = document.getElementById (this.name);
70
+ if (root == null)
71
+ return (null);
72
+
73
+ const submitBtn = root.querySelector (".fl-add-panel-submit") as HTMLButtonElement | null;
74
+ const cancelBtn = root.querySelector (".fl-add-panel-cancel") as HTMLButtonElement | null;
75
+
76
+ if (submitBtn != null)
77
+ {
78
+ submitBtn.addEventListener ("click", async (e) =>
79
+ {
80
+ e.preventDefault ();
81
+ const values = self.collectFieldValues (root);
82
+ submitBtn.disabled = true;
83
+ try
84
+ {
85
+ let keepOpen: any = false;
86
+ if (typeof self.onsave === "function")
87
+ keepOpen = await self.onsave (values);
88
+
89
+ if (keepOpen === false || keepOpen == null)
90
+ {
91
+ self.resetFields (root);
92
+ self.collapsePanel ();
93
+ self.refreshAttachedList ();
94
+ }
95
+ }
96
+ catch (ex)
97
+ {
98
+ console.error ("admin-add-panel onsave threw:", ex);
99
+ }
100
+ finally
101
+ {
102
+ submitBtn.disabled = false;
103
+ }
104
+ });
105
+ }
106
+
107
+ if (cancelBtn != null)
108
+ {
109
+ cancelBtn.addEventListener ("click", (e) =>
110
+ {
111
+ e.preventDefault ();
112
+ self.resetFields (root);
113
+ self.collapsePanel ();
114
+ });
115
+ }
116
+
117
+ return (null);
118
+ }
119
+
120
+ protected collectFieldValues (root: HTMLElement): any
121
+ {
122
+ const out: any = {};
123
+ const nodes = root.querySelectorAll ("[hot-field]");
124
+ for (let i = 0; i < nodes.length; i++)
125
+ {
126
+ const el = nodes[i] as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
127
+ const field = el.getAttribute ("hot-field");
128
+ if (field == null || field === "") continue;
129
+
130
+ if (el instanceof HTMLInputElement && el.type === "checkbox")
131
+ out[field] = el.checked;
132
+ else if (el instanceof HTMLInputElement && el.type === "number")
133
+ out[field] = el.value === "" ? null : Number (el.value);
134
+ else
135
+ out[field] = el.value;
136
+ }
137
+ return (out);
138
+ }
139
+
140
+ protected resetFields (root: HTMLElement): void
141
+ {
142
+ const nodes = root.querySelectorAll ("[hot-field]");
143
+ for (let i = 0; i < nodes.length; i++)
144
+ {
145
+ const el = nodes[i] as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
146
+ if (el instanceof HTMLInputElement && el.type === "checkbox") el.checked = false;
147
+ else if (el instanceof HTMLSelectElement) el.selectedIndex = 0;
148
+ else el.value = "";
149
+ }
150
+ }
151
+
152
+ protected collapsePanel (): void
153
+ {
154
+ const panel = document.getElementById (this.panelId);
155
+ if (panel == null) return;
156
+ panel.classList.remove ("show");
157
+ const triggers = document.querySelectorAll (`[data-bs-target="#${this.panelId}"]`);
158
+ triggers.forEach (t => t.setAttribute ("aria-expanded", "false"));
159
+ }
160
+
161
+ protected refreshAttachedList (): void
162
+ {
163
+ if (this.attached_list === "") return;
164
+ const list: any = document.getElementById (this.attached_list);
165
+ if (list != null && typeof list.refreshList === "function")
166
+ list.refreshList ();
167
+ }
168
+
169
+ output (): string | HotComponentOutput[]
170
+ {
171
+ if (this.name === "")
172
+ throw new Error ("admin-add-panel: name is required");
173
+
174
+ this.panelId = `${this.name}Panel`;
175
+ this.formId = `${this.name}Form`;
176
+
177
+ const showClass = (this.start_open === "1" || this.start_open === "true") ? " show" : "";
178
+ const ariaExp = showClass ? "true" : "false";
179
+ const titleHtml = this.title ? `<strong class="fl-add-panel-title">${this.title}</strong>` : `<span></span>`;
180
+ const cancelHtml = this.cancel_text
181
+ ? `<button type="button" class="btn btn-sm btn-link text-muted fl-add-panel-cancel">${this.cancel_text}</button>`
182
+ : "";
183
+
184
+ // Single self-contained card. Header has the toggle, body is the
185
+ // collapse panel containing the form. Bootstrap's data-bs-toggle
186
+ // drives the open/close — no JS wiring needed for that.
187
+ return (`
188
+ <div id="${this.name}" class="card fl-add-panel mb-3">
189
+ <div class="card-header d-flex justify-content-between align-items-center">
190
+ ${titleHtml}
191
+ <button type="button" class="btn btn-sm btn-primary fl-add-panel-toggle"
192
+ data-bs-toggle="collapse" data-bs-target="#${this.panelId}"
193
+ aria-expanded="${ariaExp}" aria-controls="${this.panelId}">${this.add_text}</button>
194
+ </div>
195
+ <div id="${this.panelId}" class="collapse fl-add-panel-body${showClass}">
196
+ <div class="card-body border-top">
197
+ <form id="${this.formId}" class="fl-add-panel-form">
198
+ <div class="row g-2 align-items-end">
199
+ <hot-place-here name="panelBody"></hot-place-here>
200
+ </div>
201
+ <div class="d-flex justify-content-end gap-2 mt-3">
202
+ ${cancelHtml}
203
+ <button type="submit" class="btn btn-sm btn-success fl-add-panel-submit">${this.button_title}</button>
204
+ </div>
205
+ </form>
206
+ </div>
207
+ </div>
208
+ </div>`);
209
+ }
210
+ }
@@ -0,0 +1,183 @@
1
+ import { HotStaq, Hot, HotAPI, HotComponent, HotComponentOutput } from "hotstaq";
2
+
3
+ /**
4
+ * Freelight-style card list. Renders campaign / project / member rows
5
+ * as cards on every viewport, with optional inline action buttons and
6
+ * a sub-line below the primary label.
7
+ *
8
+ * Replaces the click-row-opens-modal flow of <admin-table> + <admin-edit>:
9
+ * when `hot-detail-route` is set, the whole row becomes a link to the
10
+ * configured detail URL (e.g. "/budget/:id"). When `hot-detail-route` is
11
+ * empty, the row renders without navigation — the caller is expected to
12
+ * provide their own inline action buttons via `hot-row-actions`.
13
+ *
14
+ * Usage:
15
+ * <admin-card-table id="bankAccountsList" hot-list-url="/v1/bank_accounts/list"
16
+ * hot-detail-route="/bankAccount/:id"
17
+ * hot-primary-field="name" hot-subline-field="bankSyncAPIType"
18
+ * hot-empty-text="No bank accounts yet.">
19
+ * </admin-card-table>
20
+ *
21
+ * The component exposes a `.refreshList()` method on the rendered DOM
22
+ * element so callers (the paired admin-add-panel, an edit-save callback,
23
+ * etc.) can re-fetch without a page reload.
24
+ */
25
+ export class AdminCardTable extends HotComponent
26
+ {
27
+ /** Optional title shown in the card header. */
28
+ title: string;
29
+ /** Endpoint that returns { length, data: [...] } — usually the entity's list route. */
30
+ list_url: string;
31
+ /** JWT token to send as Authorization bearer. Empty for public endpoints. */
32
+ jwt: string;
33
+ /** Body params to POST with the list call (JSON). */
34
+ list_params: string;
35
+ /** Field name on each row used as the primary card label (default "name"). */
36
+ primary_field: string;
37
+ /** Optional second-line field rendered in muted text under the primary label. */
38
+ subline_field: string;
39
+ /** Pattern for the row's click target. ":id" interpolates row.id. Empty disables row navigation. */
40
+ detail_route: string;
41
+ /** Text shown when the list is empty. */
42
+ empty_text: string;
43
+ /** Text shown while the list is loading. */
44
+ loading_text: string;
45
+ /** Inner HTML template for the right-side action area, with ":id" placeholder. */
46
+ row_actions: string;
47
+ /** Slot name where the partner admin-add-panel injects its toggle button. */
48
+ add_slot: string;
49
+
50
+ constructor (copy: HotComponent | HotStaq, api: HotAPI)
51
+ {
52
+ super (copy, api);
53
+
54
+ this.tag = "admin-card-table";
55
+ this.title = "";
56
+ this.list_url = "";
57
+ this.jwt = "";
58
+ this.list_params = "{}";
59
+ this.primary_field = "name";
60
+ this.subline_field = "";
61
+ this.detail_route = "";
62
+ this.empty_text = "No items yet.";
63
+ this.loading_text = "Loading…";
64
+ this.row_actions = "";
65
+ this.add_slot = "";
66
+ }
67
+
68
+ onPostPlace (parentHtmlElement: HTMLElement, htmlElement: HTMLElement): HTMLElement
69
+ {
70
+ const self = this;
71
+ const container = document.getElementById (this.name);
72
+ if (container == null)
73
+ return (null);
74
+
75
+ // Expose a refreshList() method on the rendered element so the
76
+ // partner add-panel (and any other caller) can re-fetch when a
77
+ // row changes.
78
+ (container as any).refreshList = function () { return self.fetchAndRender (); };
79
+
80
+ // Initial load.
81
+ self.fetchAndRender ();
82
+
83
+ return (null);
84
+ }
85
+
86
+ protected async fetchAndRender (): Promise<void>
87
+ {
88
+ const container = document.getElementById (this.name);
89
+ const list = container ? container.querySelector (".fl-card-list") as HTMLElement : null;
90
+ if (list == null) return;
91
+
92
+ try
93
+ {
94
+ let payload: any = {};
95
+ try { payload = JSON.parse (this.list_params || "{}"); } catch (e) { payload = {}; }
96
+
97
+ const headers: any = { "Content-Type": "application/json" };
98
+ if (this.jwt) headers["Authorization"] = "Bearer " + this.jwt;
99
+
100
+ const res = await fetch (this.list_url, {
101
+ method: "POST", headers: headers, body: JSON.stringify (payload)
102
+ });
103
+
104
+ if (!res.ok)
105
+ {
106
+ list.innerHTML = `<div class="text-danger small p-3">Could not load: HTTP ${res.status}</div>`;
107
+ return;
108
+ }
109
+
110
+ const result = await res.json ();
111
+ const rows: any[] = (result && Array.isArray (result.data)) ? result.data : (Array.isArray (result) ? result : []);
112
+
113
+ if (rows.length === 0)
114
+ {
115
+ list.innerHTML = `<div class="text-muted small text-center py-4">${this.empty_text}</div>`;
116
+ return;
117
+ }
118
+
119
+ list.innerHTML = rows.map ((row) => this.renderRow (row)).join ("");
120
+ }
121
+ catch (ex)
122
+ {
123
+ list.innerHTML = `<div class="text-danger small p-3">Could not load: ${(ex as Error).message}</div>`;
124
+ }
125
+ }
126
+
127
+ protected escapeHtml (s: any): string
128
+ {
129
+ return String (s == null ? "" : s)
130
+ .replace (/&/g, "&amp;").replace (/</g, "&lt;").replace (/>/g, "&gt;")
131
+ .replace (/"/g, "&quot;").replace (/'/g, "&#39;");
132
+ }
133
+
134
+ protected interpolate (template: string, row: any): string
135
+ {
136
+ if (!template) return "";
137
+ return template.replace (/:(\w+)/g, (_m, key) => this.escapeHtml (row[key] || ""));
138
+ }
139
+
140
+ protected renderRow (row: any): string
141
+ {
142
+ const primaryRaw = row[this.primary_field];
143
+ const primary = this.escapeHtml (primaryRaw != null && primaryRaw !== "" ? primaryRaw : (row.id || ""));
144
+ const sublineRaw = this.subline_field ? row[this.subline_field] : "";
145
+ const subline = sublineRaw ? `<div class="fl-card-row-sub text-muted small">${this.escapeHtml (sublineRaw)}</div>` : "";
146
+
147
+ const inner = `
148
+ <div class="fl-card-row-main">
149
+ <div class="fl-card-row-label">${primary}</div>
150
+ ${subline}
151
+ </div>
152
+ <div class="fl-card-row-actions">${this.interpolate (this.row_actions, row)}</div>`;
153
+
154
+ if (this.detail_route)
155
+ {
156
+ const href = this.interpolate (this.detail_route, row);
157
+ return `<a href="${href}" class="fl-card-row fl-card-row-link list-group-item list-group-item-action">${inner}</a>`;
158
+ }
159
+ return `<div class="fl-card-row list-group-item">${inner}</div>`;
160
+ }
161
+
162
+ output (): string | HotComponentOutput[]
163
+ {
164
+ if (this.name === "")
165
+ throw new Error ("admin-card-table: id (name) is required");
166
+ if (this.list_url === "")
167
+ throw new Error ("admin-card-table: hot-list-url is required");
168
+
169
+ const titleHtml = this.title ? `<strong>${this.title}</strong>` : "";
170
+ const addSlotAttr = this.add_slot ? ` data-card-table-add-slot="${this.add_slot}"` : ` data-card-table-add-slot="${this.name}"`;
171
+
172
+ return (`
173
+ <div id="${this.name}" class="card fl-card-table mb-4">
174
+ <div class="card-header d-flex justify-content-between align-items-center">
175
+ ${titleHtml}
176
+ <div class="fl-card-table-actions"${addSlotAttr}></div>
177
+ </div>
178
+ <div class="fl-card-list list-group list-group-flush">
179
+ <div class="text-muted small text-center py-4">${this.loading_text}</div>
180
+ </div>
181
+ </div>`);
182
+ }
183
+ }
@@ -0,0 +1,270 @@
1
+ import { HotStaq, Hot, HotAPI, HotComponent, HotComponentOutput } from "hotstaq";
2
+
3
+ /**
4
+ * Full-page detail chrome for an entity. Replaces the modal opened by
5
+ * <admin-edit hot-type="edit"> with a dedicated page (e.g. /budget/:id)
6
+ * that has its own back link, sections, and a sticky save bar at the
7
+ * bottom.
8
+ *
9
+ * The page is responsible for placing <admin-form-field>s inside the
10
+ * `<hot-place-here name="detailBody">` slot. This component handles:
11
+ * - reading ?id from the URL
12
+ * - GET'ing the entity via hot-get-url and populating form fields by
13
+ * matching hot-field names against the response keys
14
+ * - POST'ing values to hot-save-url on Save click
15
+ * - DELETE'ing via hot-delete-url on Delete click (with confirm)
16
+ * - showing a back link to hot-back-url
17
+ *
18
+ * Usage:
19
+ * <admin-detail-page name="budgetDetail"
20
+ * hot-title="Budget"
21
+ * hot-back-url="/budgets"
22
+ * hot-back-text="← All budgets"
23
+ * hot-get-url="/v1/budgets/get"
24
+ * hot-save-url="/v1/budgets/edit"
25
+ * hot-delete-url="/v1/budgets/delete"
26
+ * hot-payload-key="budget"
27
+ * hot-jwt="${jwtToken}">
28
+ * <admin-form-field hot-field="name" hot-label="Name" hot-required="1"></admin-form-field>
29
+ * ...
30
+ * </admin-detail-page>
31
+ */
32
+ export class AdminDetailPage extends HotComponent
33
+ {
34
+ /** Heading shown at the top of the page. */
35
+ title: string;
36
+ /** URL the back link points at. */
37
+ back_url: string;
38
+ /** Text on the back link. */
39
+ back_text: string;
40
+ /** GET-by-id endpoint. POSTed with { id: <id> }. */
41
+ get_url: string;
42
+ /** Edit/save endpoint. POSTed with { [payload_key]: { id, ...fields } }. */
43
+ save_url: string;
44
+ /** Optional delete endpoint. Hides the Delete button when blank. */
45
+ delete_url: string;
46
+ /** Wrapper key in the save payload (most DAO routes expect { budget: {...} } / { issue: {...} } etc). */
47
+ payload_key: string;
48
+ /** JWT bearer for the API calls. */
49
+ jwt: string;
50
+ /** URL query param that holds the entity id (default: "id"). */
51
+ id_param: string;
52
+ /** Save button text. */
53
+ save_text: string;
54
+ /** Delete button text. */
55
+ delete_text: string;
56
+ /** Confirmation prompt for delete. */
57
+ delete_confirm: string;
58
+
59
+ constructor (copy: HotComponent | HotStaq, api: HotAPI)
60
+ {
61
+ super (copy, api);
62
+
63
+ this.tag = "admin-detail-page";
64
+ this.title = "";
65
+ this.back_url = "/";
66
+ this.back_text = "← Back";
67
+ this.get_url = "";
68
+ this.save_url = "";
69
+ this.delete_url = "";
70
+ this.payload_key = "";
71
+ this.jwt = "";
72
+ this.id_param = "id";
73
+ this.save_text = "Save";
74
+ this.delete_text = "Delete";
75
+ this.delete_confirm = "Are you sure you want to delete this?";
76
+ }
77
+
78
+ onPostPlace (parentHtmlElement: HTMLElement, htmlElement: HTMLElement): HTMLElement
79
+ {
80
+ const self = this;
81
+ const page = document.getElementById (this.name);
82
+ if (page == null) return (null);
83
+
84
+ const id = self.readIdFromUrl ();
85
+ if (!id)
86
+ {
87
+ self.showError (page, "No id provided in the URL.");
88
+ return (null);
89
+ }
90
+
91
+ // Wire buttons.
92
+ const saveBtn = page.querySelector (".fl-detail-save") as HTMLButtonElement | null;
93
+ const deleteBtn = page.querySelector (".fl-detail-delete") as HTMLButtonElement | null;
94
+
95
+ if (saveBtn != null)
96
+ saveBtn.addEventListener ("click", (e) => { e.preventDefault (); self.handleSave (page, id); });
97
+
98
+ if (deleteBtn != null)
99
+ deleteBtn.addEventListener ("click", (e) => { e.preventDefault (); self.handleDelete (id); });
100
+
101
+ // Load existing record.
102
+ self.fetchAndFill (page, id);
103
+
104
+ return (null);
105
+ }
106
+
107
+ protected readIdFromUrl (): string
108
+ {
109
+ const params = new URLSearchParams (window.location.search);
110
+ return params.get (this.id_param) || "";
111
+ }
112
+
113
+ protected showError (page: HTMLElement, msg: string): void
114
+ {
115
+ const fb = page.querySelector (".fl-detail-feedback") as HTMLElement | null;
116
+ if (fb) { fb.className = "fl-detail-feedback alert alert-danger"; fb.textContent = msg; }
117
+ }
118
+
119
+ protected showSuccess (page: HTMLElement, msg: string): void
120
+ {
121
+ const fb = page.querySelector (".fl-detail-feedback") as HTMLElement | null;
122
+ if (fb)
123
+ {
124
+ fb.className = "fl-detail-feedback alert alert-success";
125
+ fb.textContent = msg;
126
+ setTimeout (() => { fb.className = "fl-detail-feedback d-none"; fb.textContent = ""; }, 2500);
127
+ }
128
+ }
129
+
130
+ protected async fetchAndFill (page: HTMLElement, id: string): Promise<void>
131
+ {
132
+ try
133
+ {
134
+ const headers: any = { "Content-Type": "application/json" };
135
+ if (this.jwt) headers["Authorization"] = "Bearer " + this.jwt;
136
+ const res = await fetch (this.get_url, {
137
+ method: "POST", headers: headers, body: JSON.stringify ({ id: id })
138
+ });
139
+ if (!res.ok) { this.showError (page, "Could not load: HTTP " + res.status); return; }
140
+ const obj = await res.json ();
141
+ if (obj == null) { this.showError (page, "Record not found."); return; }
142
+ this.populateFields (page, obj);
143
+ }
144
+ catch (ex)
145
+ {
146
+ this.showError (page, "Could not load: " + (ex as Error).message);
147
+ }
148
+ }
149
+
150
+ protected populateFields (page: HTMLElement, obj: any): void
151
+ {
152
+ const nodes = page.querySelectorAll ("[hot-field]");
153
+ for (let i = 0; i < nodes.length; i++)
154
+ {
155
+ const el = nodes[i] as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
156
+ const field = el.getAttribute ("hot-field");
157
+ if (field == null || field === "") continue;
158
+ const val = obj[field];
159
+ if (val == null) continue;
160
+ if (el instanceof HTMLInputElement && el.type === "checkbox")
161
+ el.checked = val === true || val === "true" || val === 1;
162
+ else
163
+ el.value = String (val);
164
+ }
165
+ }
166
+
167
+ protected collectValues (page: HTMLElement): any
168
+ {
169
+ const out: any = {};
170
+ const nodes = page.querySelectorAll ("[hot-field]");
171
+ for (let i = 0; i < nodes.length; i++)
172
+ {
173
+ const el = nodes[i] as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
174
+ const field = el.getAttribute ("hot-field");
175
+ if (field == null || field === "") continue;
176
+ if (el instanceof HTMLInputElement && el.type === "checkbox")
177
+ out[field] = el.checked;
178
+ else if (el instanceof HTMLInputElement && el.type === "number")
179
+ out[field] = el.value === "" ? null : Number (el.value);
180
+ else
181
+ out[field] = el.value;
182
+ }
183
+ return out;
184
+ }
185
+
186
+ protected async handleSave (page: HTMLElement, id: string): Promise<void>
187
+ {
188
+ const values = this.collectValues (page);
189
+ values.id = id;
190
+ const body: any = this.payload_key ? { [this.payload_key]: values } : values;
191
+
192
+ const btn = page.querySelector (".fl-detail-save") as HTMLButtonElement | null;
193
+ if (btn) btn.disabled = true;
194
+ try
195
+ {
196
+ const headers: any = { "Content-Type": "application/json" };
197
+ if (this.jwt) headers["Authorization"] = "Bearer " + this.jwt;
198
+ const res = await fetch (this.save_url, {
199
+ method: "POST", headers: headers, body: JSON.stringify (body)
200
+ });
201
+ if (!res.ok)
202
+ {
203
+ let msg = "HTTP " + res.status;
204
+ try { const j = await res.json (); if (j && j.error) msg = j.error; } catch (e) {}
205
+ this.showError (page, "Save failed: " + msg);
206
+ return;
207
+ }
208
+ this.showSuccess (page, "Saved.");
209
+ }
210
+ catch (ex)
211
+ {
212
+ this.showError (page, "Save failed: " + (ex as Error).message);
213
+ }
214
+ finally
215
+ {
216
+ if (btn) btn.disabled = false;
217
+ }
218
+ }
219
+
220
+ protected async handleDelete (id: string): Promise<void>
221
+ {
222
+ if (!window.confirm (this.delete_confirm)) return;
223
+ try
224
+ {
225
+ const headers: any = { "Content-Type": "application/json" };
226
+ if (this.jwt) headers["Authorization"] = "Bearer " + this.jwt;
227
+ const res = await fetch (this.delete_url, {
228
+ method: "POST", headers: headers, body: JSON.stringify ({ id: id })
229
+ });
230
+ if (!res.ok) { alert ("Delete failed: HTTP " + res.status); return; }
231
+ window.location.href = this.back_url;
232
+ }
233
+ catch (ex)
234
+ {
235
+ alert ("Delete failed: " + (ex as Error).message);
236
+ }
237
+ }
238
+
239
+ output (): string | HotComponentOutput[]
240
+ {
241
+ if (this.name === "")
242
+ throw new Error ("admin-detail-page: id (name) is required");
243
+ if (this.get_url === "" || this.save_url === "")
244
+ throw new Error ("admin-detail-page: hot-get-url and hot-save-url are required");
245
+
246
+ const deleteBtn = this.delete_url
247
+ ? `<button type="button" class="btn btn-outline-danger fl-detail-delete">${this.delete_text}</button>`
248
+ : "";
249
+
250
+ return (`
251
+ <div id="${this.name}" class="fl-detail-page">
252
+ <div class="container" style="max-width:880px;padding:1.5rem 1rem 7rem;">
253
+ <div class="mb-3"><a href="${this.back_url}" class="text-muted small text-decoration-none">${this.back_text}</a></div>
254
+ <h1 class="h3 mb-3">${this.title}</h1>
255
+ <div class="fl-detail-feedback d-none"></div>
256
+ <div class="card mb-3"><div class="card-body">
257
+ <div class="row g-3">
258
+ <hot-place-here name="detailBody"></hot-place-here>
259
+ </div>
260
+ </div></div>
261
+ </div>
262
+ <div class="fl-detail-save-bar">
263
+ <div class="container d-flex justify-content-between align-items-center" style="max-width:880px;">
264
+ ${deleteBtn}
265
+ <button type="button" class="btn btn-primary fl-detail-save ms-auto">${this.save_text}</button>
266
+ </div>
267
+ </div>
268
+ </div>`);
269
+ }
270
+ }