@modernman00/shared-js-lib 1.2.90 → 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.
@@ -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
+ }
@@ -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
+ }