@modernman00/shared-js-lib 1.2.89 → 1.6.2
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/AddressAutocomplete.js +164 -15
- package/AiBillSplitter.js +230 -0
- package/AiEntertainmentPrompt.js +130 -0
- package/AiFlyerStudio.js +215 -0
- package/AiMediaGuard.js +95 -0
- package/AiPrivacyGuard.js +63 -0
- package/AiSemanticSearch.js +107 -0
- package/AiVoiceRecorder.js +196 -0
- package/CanvasPhysicsEngine.js +568 -0
- package/FileUpload.js +182 -1
- package/HyperReality3DEngine.js +404 -0
- package/Loader.js +6 -2
- package/PwaInstall.js +20 -2
- package/README.md +78 -0
- package/SovereignBroadcasterClient.js +325 -0
- package/VisualSupremacyRuntime.js +312 -0
- package/WebAuthnClient.js +470 -0
- package/WebPushClient.js +308 -0
- package/axiosWrapper.js +1 -0
- package/config/version.php +1 -1
- package/index.js +13 -1
- package/package.json +14 -1
- package/release-history.md +6 -0
- package/templates/sw.template.js +105 -57
package/AiFlyerStudio.js
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AiFlyerStudio
|
|
3
|
+
*
|
|
4
|
+
* Couples generative AI background art with native client-side HTML5 Canvas typography.
|
|
5
|
+
* Eliminates garbled AI text by rendering crisp, localized vector typography, shadows,
|
|
6
|
+
* and decorative badges over high-resolution diffusion backgrounds.
|
|
7
|
+
*/
|
|
8
|
+
export class AiFlyerStudio {
|
|
9
|
+
/**
|
|
10
|
+
* @param {Object} options
|
|
11
|
+
* @param {HTMLCanvasElement} [options.canvas]
|
|
12
|
+
* @param {number} [options.width=800]
|
|
13
|
+
* @param {number} [options.height=1200]
|
|
14
|
+
*/
|
|
15
|
+
constructor(options = {}) {
|
|
16
|
+
this.width = options.width || 800;
|
|
17
|
+
this.height = options.height || 1200;
|
|
18
|
+
this.canvas = options.canvas || (typeof document !== 'undefined' ? document.createElement('canvas') : null);
|
|
19
|
+
|
|
20
|
+
if (this.canvas) {
|
|
21
|
+
this.canvas.width = this.width;
|
|
22
|
+
this.canvas.height = this.height;
|
|
23
|
+
this.ctx = this.canvas.getContext('2d');
|
|
24
|
+
} else {
|
|
25
|
+
this.ctx = null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
this.backgroundImage = null;
|
|
29
|
+
this.flyerData = {
|
|
30
|
+
title: 'EVENT TITLE',
|
|
31
|
+
subtitle: '',
|
|
32
|
+
date: '',
|
|
33
|
+
time: '',
|
|
34
|
+
location: '',
|
|
35
|
+
hostName: '',
|
|
36
|
+
rsvpNote: ''
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Set the background image source (Data URI or URL)
|
|
42
|
+
* @param {string|HTMLImageElement} src
|
|
43
|
+
* @returns {Promise<void>}
|
|
44
|
+
*/
|
|
45
|
+
async setBackgroundImage(src) {
|
|
46
|
+
if (typeof src === 'string') {
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
if (typeof Image === 'undefined') {
|
|
49
|
+
resolve();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const img = new Image();
|
|
53
|
+
img.crossOrigin = 'anonymous';
|
|
54
|
+
img.onload = () => {
|
|
55
|
+
this.backgroundImage = img;
|
|
56
|
+
resolve();
|
|
57
|
+
};
|
|
58
|
+
img.onerror = (e) => reject(new Error('Failed to load background image: ' + e));
|
|
59
|
+
img.src = src;
|
|
60
|
+
});
|
|
61
|
+
} else {
|
|
62
|
+
this.backgroundImage = src;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Update text fields
|
|
68
|
+
* @param {Object} data
|
|
69
|
+
*/
|
|
70
|
+
setData(data = {}) {
|
|
71
|
+
this.flyerData = { ...this.flyerData, ...data };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Render the complete poster onto the canvas
|
|
76
|
+
*/
|
|
77
|
+
render() {
|
|
78
|
+
if (!this.ctx) return;
|
|
79
|
+
const ctx = this.ctx;
|
|
80
|
+
const w = this.width;
|
|
81
|
+
const h = this.height;
|
|
82
|
+
|
|
83
|
+
// 1. Draw Background
|
|
84
|
+
if (this.backgroundImage) {
|
|
85
|
+
ctx.drawImage(this.backgroundImage, 0, 0, w, h);
|
|
86
|
+
} else {
|
|
87
|
+
// Dark gradient fallback
|
|
88
|
+
const grad = ctx.createLinearGradient(0, 0, 0, h);
|
|
89
|
+
grad.addColorStop(0, '#0f172a');
|
|
90
|
+
grad.addColorStop(1, '#020617');
|
|
91
|
+
ctx.fillStyle = grad;
|
|
92
|
+
ctx.fillRect(0, 0, w, h);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 2. Subtle Dark Scrim Overlay in text zones for guaranteed legibility
|
|
96
|
+
const scrim = ctx.createLinearGradient(0, 0, 0, h);
|
|
97
|
+
scrim.addColorStop(0, 'rgba(0, 0, 0, 0.4)');
|
|
98
|
+
scrim.addColorStop(0.3, 'rgba(0, 0, 0, 0.15)');
|
|
99
|
+
scrim.addColorStop(0.7, 'rgba(0, 0, 0, 0.3)');
|
|
100
|
+
scrim.addColorStop(1, 'rgba(0, 0, 0, 0.7)');
|
|
101
|
+
ctx.fillStyle = scrim;
|
|
102
|
+
ctx.fillRect(0, 0, w, h);
|
|
103
|
+
|
|
104
|
+
// 3. Render Header / Host Badge
|
|
105
|
+
if (this.flyerData.hostName) {
|
|
106
|
+
ctx.save();
|
|
107
|
+
ctx.font = '600 24px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
|
108
|
+
ctx.fillStyle = '#f8fafc';
|
|
109
|
+
ctx.textAlign = 'center';
|
|
110
|
+
ctx.shadowColor = 'rgba(0,0,0,0.8)';
|
|
111
|
+
ctx.shadowBlur = 10;
|
|
112
|
+
ctx.fillText(this.flyerData.hostName.toUpperCase() + ' PRESENTS', w / 2, 90);
|
|
113
|
+
ctx.restore();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 4. Render Main Title
|
|
117
|
+
ctx.save();
|
|
118
|
+
ctx.font = '900 64px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
|
119
|
+
ctx.fillStyle = '#ffffff';
|
|
120
|
+
ctx.textAlign = 'center';
|
|
121
|
+
ctx.shadowColor = '#000000';
|
|
122
|
+
ctx.shadowBlur = 16;
|
|
123
|
+
ctx.shadowOffsetX = 0;
|
|
124
|
+
ctx.shadowOffsetY = 4;
|
|
125
|
+
ctx.fillText(this.flyerData.title.toUpperCase(), w / 2, h * 0.48);
|
|
126
|
+
ctx.restore();
|
|
127
|
+
|
|
128
|
+
// 5. Render Subtitle / Theme
|
|
129
|
+
if (this.flyerData.subtitle) {
|
|
130
|
+
ctx.save();
|
|
131
|
+
ctx.font = '500 28px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
|
132
|
+
ctx.fillStyle = '#facc15'; // Accent gold
|
|
133
|
+
ctx.textAlign = 'center';
|
|
134
|
+
ctx.shadowColor = 'rgba(0,0,0,0.9)';
|
|
135
|
+
ctx.shadowBlur = 8;
|
|
136
|
+
ctx.fillText(this.flyerData.subtitle, w / 2, h * 0.54);
|
|
137
|
+
ctx.restore();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// 6. Render Date & Time Pill Card
|
|
141
|
+
if (this.flyerData.date || this.flyerData.time) {
|
|
142
|
+
const dateTimeText = [this.flyerData.date, this.flyerData.time].filter(Boolean).join(' • ');
|
|
143
|
+
ctx.save();
|
|
144
|
+
ctx.font = '700 28px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
|
145
|
+
ctx.fillStyle = '#ffffff';
|
|
146
|
+
ctx.textAlign = 'center';
|
|
147
|
+
ctx.shadowColor = 'rgba(0,0,0,0.8)';
|
|
148
|
+
ctx.shadowBlur = 10;
|
|
149
|
+
ctx.fillText(dateTimeText, w / 2, h * 0.78);
|
|
150
|
+
ctx.restore();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 7. Render Location
|
|
154
|
+
if (this.flyerData.location) {
|
|
155
|
+
ctx.save();
|
|
156
|
+
ctx.font = '600 24px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
|
157
|
+
ctx.fillStyle = '#94a3b8';
|
|
158
|
+
ctx.textAlign = 'center';
|
|
159
|
+
ctx.shadowColor = 'rgba(0,0,0,0.8)';
|
|
160
|
+
ctx.shadowBlur = 8;
|
|
161
|
+
ctx.fillText(this.flyerData.location, w / 2, h * 0.83);
|
|
162
|
+
ctx.restore();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 8. Render RSVP Note
|
|
166
|
+
if (this.flyerData.rsvpNote) {
|
|
167
|
+
ctx.save();
|
|
168
|
+
ctx.font = '500 20px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
|
169
|
+
ctx.fillStyle = '#cbd5e1';
|
|
170
|
+
ctx.textAlign = 'center';
|
|
171
|
+
ctx.fillText(this.flyerData.rsvpNote, w / 2, h * 0.92);
|
|
172
|
+
ctx.restore();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Export canvas as Data URL
|
|
178
|
+
* @param {string} [format='image/png']
|
|
179
|
+
* @param {number} [quality=0.92]
|
|
180
|
+
* @returns {string}
|
|
181
|
+
*/
|
|
182
|
+
toDataURL(format = 'image/png', quality = 0.92) {
|
|
183
|
+
if (!this.canvas) return '';
|
|
184
|
+
return this.canvas.toDataURL(format, quality);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Export canvas as Blob
|
|
189
|
+
* @param {Function} callback
|
|
190
|
+
* @param {string} [format='image/png']
|
|
191
|
+
*/
|
|
192
|
+
toBlob(callback, format = 'image/png') {
|
|
193
|
+
if (!this.canvas || typeof this.canvas.toBlob !== 'function') {
|
|
194
|
+
if (typeof callback === 'function') callback(null);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
this.canvas.toBlob(callback, format);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Trigger native browser download
|
|
202
|
+
* @param {string} [filename='party-flyer.png']
|
|
203
|
+
*/
|
|
204
|
+
download(filename = 'party-flyer.png') {
|
|
205
|
+
if (typeof document === 'undefined') return;
|
|
206
|
+
const url = this.toDataURL();
|
|
207
|
+
if (!url) return;
|
|
208
|
+
const a = document.createElement('a');
|
|
209
|
+
a.href = url;
|
|
210
|
+
a.download = filename;
|
|
211
|
+
document.body.appendChild(a);
|
|
212
|
+
a.click();
|
|
213
|
+
document.body.removeChild(a);
|
|
214
|
+
}
|
|
215
|
+
}
|
package/AiMediaGuard.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import axios from './axiosWrapper.js';
|
|
2
|
+
import { swalToast, swalError } from './Alert.js';
|
|
3
|
+
import { setButtonLoading, clearButtonLoading } from './ButtonLoader.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* AiMediaGuard — Client-Side Upload Armor & Real-Time Content Safety Check
|
|
7
|
+
*
|
|
8
|
+
* Applicable to:
|
|
9
|
+
* - PartyPlatform: Guest photo uploads to the Live Reception Wall
|
|
10
|
+
* - FamilyPlatform: Photo album & story uploads
|
|
11
|
+
*/
|
|
12
|
+
export class AiMediaGuard {
|
|
13
|
+
/**
|
|
14
|
+
* @param {Object} [options={}]
|
|
15
|
+
* @param {number} [options.maxFileSizeBytes=15728640] Max 15MB file size
|
|
16
|
+
* @param {string[]} [options.allowedMimeTypes=['image/jpeg', 'image/png', 'image/webp']]
|
|
17
|
+
*/
|
|
18
|
+
constructor(options = {}) {
|
|
19
|
+
this.maxFileSizeBytes = options.maxFileSizeBytes || 15 * 1024 * 1024;
|
|
20
|
+
this.allowedMimeTypes = options.allowedMimeTypes || ['image/jpeg', 'image/png', 'image/webp'];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Inspect file constraints on client-side before network upload.
|
|
25
|
+
*
|
|
26
|
+
* @param {File} file
|
|
27
|
+
* @returns {{valid: boolean, error?: string}}
|
|
28
|
+
*/
|
|
29
|
+
validateFile(file) {
|
|
30
|
+
if (!file) {
|
|
31
|
+
return { valid: false, error: 'No file selected.' };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!this.allowedMimeTypes.includes(file.type)) {
|
|
35
|
+
return { valid: false, error: 'Invalid file format. Please upload JPG, PNG, or WEBP.' };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (file.size > this.maxFileSizeBytes) {
|
|
39
|
+
const maxMb = Math.round(this.maxFileSizeBytes / (1024 * 1024));
|
|
40
|
+
return { valid: false, error: `File size exceeds the ${maxMb}MB limit.` };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return { valid: true };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Send file preview to backend moderation endpoint to verify safety before final post.
|
|
48
|
+
*
|
|
49
|
+
* @param {File} file
|
|
50
|
+
* @param {string} moderationUrl e.g. '/api/ai/moderate-photo'
|
|
51
|
+
* @param {Object} [options={}]
|
|
52
|
+
* @param {string|HTMLElement} [options.button=null]
|
|
53
|
+
* @returns {Promise<{isSafe: boolean, error?: string}>}
|
|
54
|
+
*/
|
|
55
|
+
static async precheckPhotoSafety(file, moderationUrl, options = {}) {
|
|
56
|
+
if (!file) {
|
|
57
|
+
return { isSafe: false, error: 'No file provided' };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const buttonTarget = options.button || null;
|
|
61
|
+
if (buttonTarget) {
|
|
62
|
+
setButtonLoading(buttonTarget, 'Checking safety...');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const formData = new FormData();
|
|
67
|
+
formData.append('photo', file);
|
|
68
|
+
|
|
69
|
+
const response = await axios.post(moderationUrl, formData, {
|
|
70
|
+
headers: { 'Content-Type': 'multipart/form-data' },
|
|
71
|
+
timeout: 8000,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const data = response?.data || {};
|
|
75
|
+
|
|
76
|
+
if (data.flagged || data.is_safe === false) {
|
|
77
|
+
swalError(
|
|
78
|
+
'Content Flagged',
|
|
79
|
+
'This image was flagged by automated content safety guidelines. Please choose a different photo.'
|
|
80
|
+
);
|
|
81
|
+
return { isSafe: false, error: 'Content flagged as unsafe' };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { isSafe: true };
|
|
85
|
+
} catch (err) {
|
|
86
|
+
console.error('[AiMediaGuard] Safety check error:', err);
|
|
87
|
+
// In case of network timeout on moderation, allow user to proceed or hold based on policy
|
|
88
|
+
return { isSafe: true };
|
|
89
|
+
} finally {
|
|
90
|
+
if (buttonTarget) {
|
|
91
|
+
clearButtonLoading(buttonTarget);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AiPrivacyGuard (Client-Side)
|
|
3
|
+
*
|
|
4
|
+
* Intercepts user inputs before sending to AI endpoints and ensures that
|
|
5
|
+
* sensitive Personally Identifiable Information (PII) such as:
|
|
6
|
+
* - Dates of Birth (DOB)
|
|
7
|
+
* - UK Postcodes
|
|
8
|
+
* - Full Street Addresses
|
|
9
|
+
* - UK Phone Numbers
|
|
10
|
+
* - Email Addresses
|
|
11
|
+
* - Financial Card Numbers
|
|
12
|
+
*
|
|
13
|
+
* are scrubbed and replaced with anonymous placeholders.
|
|
14
|
+
*/
|
|
15
|
+
export class AiPrivacyGuard {
|
|
16
|
+
static REGEX_UK_POSTCODE = /\b(?:[A-Z]{1,2}[0-9][A-Z0-9]?\s?[0-9][A-Z]{2})\b/gi;
|
|
17
|
+
static REGEX_EMAIL = /\b[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b/g;
|
|
18
|
+
static REGEX_UK_PHONE = /(?:\+44\s?7\d{3}|\+44\s?[12]\d{3}|0044\s?7\d{3}|07\d{3}|01\d{3}|02\d{3})\s?\d{3}\s?\d{3,4}\b/g;
|
|
19
|
+
static REGEX_CALENDAR_DATE = /\b(?:\d{4}[-/](?:0[1-9]|1[0-2])[-/](?:0[1-9]|[12]\d|3[01])|(?:0[1-9]|[12]\d|3[01])[-/](?:0[1-9]|1[0-2])[-/]\d{2,4})\b/g;
|
|
20
|
+
static REGEX_DOB_DECLARATION = /\b(?:d\.?o\.?b\.?|born\s+(?:on|in)?)\s*:?\s*(?:[0-9]{1,2}(?:st|nd|rd|th)?\s+(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\s+[0-9]{4}|[0-9]{1,2}[-/][0-9]{1,2}[-/][0-9]{2,4})/gi;
|
|
21
|
+
static REGEX_STREET_ADDRESS = /\b(?:\d{1,4}[a-zA-Z]?\s+)?(?:Flat\s+\d+,\s*)?(?:[A-Z][a-z]+\s+)?(?:Street|Road|Avenue|Lane|Drive|Close|Way|Gardens|Crescent|Grove|Boulevard|Court|Square|Walk)\b/gi;
|
|
22
|
+
static REGEX_UK_NINO = /\b[A-Z]{2}\s?(?:\d\s?){6}[A-D]\b/gi;
|
|
23
|
+
static REGEX_CREDIT_CARD = /\b(?:\d{4}[-\s]?){3}\d{4}\b/g;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Scrub PII from text before dispatching to any AI endpoint.
|
|
27
|
+
*
|
|
28
|
+
* @param {string} text
|
|
29
|
+
* @returns {string} Scrubbed text
|
|
30
|
+
*/
|
|
31
|
+
static scrubText(text) {
|
|
32
|
+
if (!text || typeof text !== 'string') return '';
|
|
33
|
+
return text
|
|
34
|
+
.replace(this.REGEX_UK_NINO, '[REDACTED_NINO]')
|
|
35
|
+
.replace(this.REGEX_CREDIT_CARD, '[REDACTED_FINANCIAL]')
|
|
36
|
+
.replace(this.REGEX_DOB_DECLARATION, '[REDACTED_DOB]')
|
|
37
|
+
.replace(this.REGEX_CALENDAR_DATE, '[REDACTED_DATE]')
|
|
38
|
+
.replace(this.REGEX_UK_POSTCODE, '[REDACTED_POSTCODE]')
|
|
39
|
+
.replace(this.REGEX_STREET_ADDRESS, '[REDACTED_ADDRESS]')
|
|
40
|
+
.replace(this.REGEX_UK_PHONE, '[REDACTED_PHONE]')
|
|
41
|
+
.replace(this.REGEX_EMAIL, '[REDACTED_EMAIL]');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Check if a given text contains PII.
|
|
46
|
+
*
|
|
47
|
+
* @param {string} text
|
|
48
|
+
* @returns {boolean}
|
|
49
|
+
*/
|
|
50
|
+
static containsPii(text) {
|
|
51
|
+
if (!text || typeof text !== 'string') return false;
|
|
52
|
+
return (
|
|
53
|
+
this.REGEX_UK_NINO.test(text) ||
|
|
54
|
+
this.REGEX_CREDIT_CARD.test(text) ||
|
|
55
|
+
this.REGEX_DOB_DECLARATION.test(text) ||
|
|
56
|
+
this.REGEX_CALENDAR_DATE.test(text) ||
|
|
57
|
+
this.REGEX_UK_POSTCODE.test(text) ||
|
|
58
|
+
this.REGEX_STREET_ADDRESS.test(text) ||
|
|
59
|
+
this.REGEX_UK_PHONE.test(text) ||
|
|
60
|
+
this.REGEX_EMAIL.test(text)
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import axios from './axiosWrapper.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AiSemanticSearch — Debounced Semantic Search Input Controller
|
|
5
|
+
*
|
|
6
|
+
* Applicable to:
|
|
7
|
+
* - FamilyPlatform: Natural language family archive search
|
|
8
|
+
* - ExecMind: Board minute & strategic plan semantic search
|
|
9
|
+
* - PartyPlatform: Guest interest & music catalog lookup
|
|
10
|
+
*/
|
|
11
|
+
export class AiSemanticSearch {
|
|
12
|
+
/**
|
|
13
|
+
* @param {Object} options
|
|
14
|
+
* @param {HTMLInputElement|string} options.input Target search input element or CSS selector
|
|
15
|
+
* @param {string} options.searchUrl Endpoint URL, e.g. '/api/ai/semantic-search'
|
|
16
|
+
* @param {Function} options.onResults Callback receiving array of ranked search results
|
|
17
|
+
* @param {Function} [options.onLoading=null] Callback receiving boolean loading state
|
|
18
|
+
* @param {number} [options.debounceMs=350] Input debounce delay (default: 350ms)
|
|
19
|
+
* @param {number} [options.minQueryLength=2] Minimum chars required before searching
|
|
20
|
+
*/
|
|
21
|
+
constructor(options = {}) {
|
|
22
|
+
this.inputEl = typeof options.input === 'string'
|
|
23
|
+
? document.querySelector(options.input)
|
|
24
|
+
: options.input;
|
|
25
|
+
|
|
26
|
+
this.searchUrl = options.searchUrl || '';
|
|
27
|
+
this.onResults = options.onResults || (() => {});
|
|
28
|
+
this.onLoading = options.onLoading || (() => {});
|
|
29
|
+
this.debounceMs = options.debounceMs || 350;
|
|
30
|
+
this.minQueryLength = options.minQueryLength || 2;
|
|
31
|
+
|
|
32
|
+
this.debounceTimer = null;
|
|
33
|
+
this.currentAbortController = null;
|
|
34
|
+
|
|
35
|
+
if (this.inputEl && this.inputEl instanceof HTMLInputElement) {
|
|
36
|
+
this.bindEvents();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Attach input listener with debounce.
|
|
42
|
+
*/
|
|
43
|
+
bindEvents() {
|
|
44
|
+
this.inputEl.addEventListener('input', () => {
|
|
45
|
+
const query = this.inputEl.value.trim();
|
|
46
|
+
|
|
47
|
+
if (this.debounceTimer) {
|
|
48
|
+
clearTimeout(this.debounceTimer);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (query.length < this.minQueryLength) {
|
|
52
|
+
this.onResults([]);
|
|
53
|
+
this.onLoading(false);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
this.debounceTimer = setTimeout(() => {
|
|
58
|
+
this.search(query);
|
|
59
|
+
}, this.debounceMs);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Execute semantic search request.
|
|
65
|
+
*
|
|
66
|
+
* @param {string} query
|
|
67
|
+
* @returns {Promise<Array>} Ranked results
|
|
68
|
+
*/
|
|
69
|
+
async search(query) {
|
|
70
|
+
if (!query || query.length < this.minQueryLength) {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Cancel previous pending search request
|
|
75
|
+
if (this.currentAbortController) {
|
|
76
|
+
this.currentAbortController.abort();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
this.currentAbortController = new AbortController();
|
|
80
|
+
this.onLoading(true);
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const response = await axios.post(
|
|
84
|
+
this.searchUrl,
|
|
85
|
+
{ query },
|
|
86
|
+
{
|
|
87
|
+
signal: this.currentAbortController.signal,
|
|
88
|
+
timeout: 8000,
|
|
89
|
+
}
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const results = response?.data?.results || response?.data || [];
|
|
93
|
+
const resultsArr = Array.isArray(results) ? results : [];
|
|
94
|
+
|
|
95
|
+
this.onResults(resultsArr);
|
|
96
|
+
return resultsArr;
|
|
97
|
+
} catch (err) {
|
|
98
|
+
if (err.name !== 'CanceledError' && err.name !== 'AbortError') {
|
|
99
|
+
console.error('[AiSemanticSearch] Search error:', err);
|
|
100
|
+
this.onResults([]);
|
|
101
|
+
}
|
|
102
|
+
return [];
|
|
103
|
+
} finally {
|
|
104
|
+
this.onLoading(false);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import axios from './axiosWrapper.js';
|
|
2
|
+
import { swalToast, swalError } from './Alert.js';
|
|
3
|
+
import { setButtonLoading, clearButtonLoading } from './ButtonLoader.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* AiVoiceRecorder — Browser MediaRecorder & Automated Speech Transcription Bridge
|
|
7
|
+
*
|
|
8
|
+
* Implements:
|
|
9
|
+
* - High-fidelity browser audio recording via MediaStream API
|
|
10
|
+
* - Automatic MIME type negotiation (audio/webm, audio/mp4, audio/ogg)
|
|
11
|
+
* - Seamless FormData upload to backend transcription endpoints
|
|
12
|
+
*
|
|
13
|
+
* Applicable to:
|
|
14
|
+
* - PartyPlatform: Guest audio keepsakes & DJ shoutouts
|
|
15
|
+
* - FamilyPlatform: Oral histories & family voice recordings
|
|
16
|
+
* - BCP: Carer mobile shift dictation
|
|
17
|
+
*/
|
|
18
|
+
export class AiVoiceRecorder {
|
|
19
|
+
/**
|
|
20
|
+
* @param {Object} [options={}]
|
|
21
|
+
* @param {number} [options.maxDurationMs=60000] Maximum recording limit (default: 60s)
|
|
22
|
+
* @param {Function} [options.onStateChange=null] Callback invoked on state changes
|
|
23
|
+
*/
|
|
24
|
+
constructor(options = {}) {
|
|
25
|
+
this.maxDurationMs = options.maxDurationMs || 60000;
|
|
26
|
+
this.onStateChange = options.onStateChange || null;
|
|
27
|
+
this.mediaRecorder = null;
|
|
28
|
+
this.audioChunks = [];
|
|
29
|
+
this.stream = null;
|
|
30
|
+
this.timeoutTimer = null;
|
|
31
|
+
this.isRecording = false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Check if the client browser supports MediaRecorder audio capture.
|
|
36
|
+
* @returns {boolean}
|
|
37
|
+
*/
|
|
38
|
+
static isSupported() {
|
|
39
|
+
return typeof window !== 'undefined'
|
|
40
|
+
&& !!(window.navigator?.mediaDevices?.getUserMedia)
|
|
41
|
+
&& typeof window.MediaRecorder !== 'undefined';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Start audio recording with microphone permission prompt.
|
|
46
|
+
* @returns {Promise<boolean>} True if recording started successfully
|
|
47
|
+
*/
|
|
48
|
+
async start() {
|
|
49
|
+
if (!AiVoiceRecorder.isSupported()) {
|
|
50
|
+
swalToast('Voice recording is not supported in this browser.', 'warning');
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
this.stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
56
|
+
this.audioChunks = [];
|
|
57
|
+
|
|
58
|
+
// Select best supported MIME type
|
|
59
|
+
let mimeType = 'audio/webm';
|
|
60
|
+
if (MediaRecorder.isTypeSupported('audio/webm;codecs=opus')) {
|
|
61
|
+
mimeType = 'audio/webm;codecs=opus';
|
|
62
|
+
} else if (MediaRecorder.isTypeSupported('audio/mp4')) {
|
|
63
|
+
mimeType = 'audio/mp4';
|
|
64
|
+
} else if (MediaRecorder.isTypeSupported('audio/ogg')) {
|
|
65
|
+
mimeType = 'audio/ogg';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
this.mediaRecorder = new MediaRecorder(this.stream, { mimeType });
|
|
69
|
+
|
|
70
|
+
this.mediaRecorder.ondataavailable = (event) => {
|
|
71
|
+
if (event.data && event.data.size > 0) {
|
|
72
|
+
this.audioChunks.push(event.data);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
this.mediaRecorder.start(250); // Emit chunk every 250ms
|
|
77
|
+
this.isRecording = true;
|
|
78
|
+
this._notifyState('recording');
|
|
79
|
+
|
|
80
|
+
// Automatic timeout safety guard
|
|
81
|
+
this.timeoutTimer = setTimeout(() => {
|
|
82
|
+
if (this.isRecording) {
|
|
83
|
+
this.stop();
|
|
84
|
+
swalToast('Maximum recording limit reached.', 'info');
|
|
85
|
+
}
|
|
86
|
+
}, this.maxDurationMs);
|
|
87
|
+
|
|
88
|
+
return true;
|
|
89
|
+
} catch (err) {
|
|
90
|
+
console.error('[AiVoiceRecorder] Microphone access error:', err);
|
|
91
|
+
swalError('Microphone Access Denied', 'Please allow microphone access in your browser to record audio.');
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Stop recording and resolve the captured audio as a Blob.
|
|
98
|
+
* @returns {Promise<Blob|null>} Captured audio Blob or null
|
|
99
|
+
*/
|
|
100
|
+
stop() {
|
|
101
|
+
return new Promise((resolve) => {
|
|
102
|
+
if (!this.mediaRecorder || !this.isRecording) {
|
|
103
|
+
resolve(null);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (this.timeoutTimer) {
|
|
108
|
+
clearTimeout(this.timeoutTimer);
|
|
109
|
+
this.timeoutTimer = null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
this.mediaRecorder.onstop = () => {
|
|
113
|
+
this.isRecording = false;
|
|
114
|
+
this._notifyState('stopped');
|
|
115
|
+
|
|
116
|
+
// Stop microphone hardware stream
|
|
117
|
+
if (this.stream) {
|
|
118
|
+
this.stream.getTracks().forEach((track) => track.stop());
|
|
119
|
+
this.stream = null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const mimeType = this.mediaRecorder?.mimeType || 'audio/webm';
|
|
123
|
+
const audioBlob = new Blob(this.audioChunks, { type: mimeType });
|
|
124
|
+
resolve(audioBlob);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
this.mediaRecorder.stop();
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Upload captured audio Blob to a backend transcription endpoint.
|
|
133
|
+
*
|
|
134
|
+
* @param {Blob} audioBlob
|
|
135
|
+
* @param {string} uploadUrl e.g. '/api/ai/transcribe'
|
|
136
|
+
* @param {Object} [options={}]
|
|
137
|
+
* @param {string|HTMLElement} [options.button=null] Optional button target for loading spinner
|
|
138
|
+
* @param {Object} [options.additionalData={}] Extra key-value pairs appended to FormData
|
|
139
|
+
* @returns {Promise<{success: boolean, text: string, error?: string}>}
|
|
140
|
+
*/
|
|
141
|
+
static async uploadAndTranscribe(audioBlob, uploadUrl, options = {}) {
|
|
142
|
+
if (!audioBlob || audioBlob.size === 0) {
|
|
143
|
+
return { success: false, text: '', error: 'No audio recorded' };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const buttonTarget = options.button || null;
|
|
147
|
+
if (buttonTarget) {
|
|
148
|
+
setButtonLoading(buttonTarget, 'Transcribing...');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
const formData = new FormData();
|
|
153
|
+
const extension = audioBlob.type.includes('mp4') ? 'mp4' : 'webm';
|
|
154
|
+
formData.append('audio_file', audioBlob, `voice_recording.${extension}`);
|
|
155
|
+
|
|
156
|
+
if (options.additionalData && typeof options.additionalData === 'object') {
|
|
157
|
+
Object.entries(options.additionalData).forEach(([key, val]) => {
|
|
158
|
+
formData.append(key, String(val));
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const response = await axios.post(uploadUrl, formData, {
|
|
163
|
+
headers: { 'Content-Type': 'multipart/form-data' },
|
|
164
|
+
timeout: 20000,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const data = response?.data || {};
|
|
168
|
+
if (data.success && typeof data.text === 'string') {
|
|
169
|
+
return { success: true, text: data.text };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
success: false,
|
|
174
|
+
text: '',
|
|
175
|
+
error: data.message || data.error || 'Transcription failed',
|
|
176
|
+
};
|
|
177
|
+
} catch (err) {
|
|
178
|
+
console.error('[AiVoiceRecorder] Upload error:', err);
|
|
179
|
+
return { success: false, text: '', error: err.message || 'Upload network error' };
|
|
180
|
+
} finally {
|
|
181
|
+
if (buttonTarget) {
|
|
182
|
+
clearButtonLoading(buttonTarget);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
_notifyState(state) {
|
|
188
|
+
if (typeof this.onStateChange === 'function') {
|
|
189
|
+
try {
|
|
190
|
+
this.onStateChange(state);
|
|
191
|
+
} catch (e) {
|
|
192
|
+
console.error('[AiVoiceRecorder] State callback error:', e);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|