PageSourceSearch

https://powercoach.ai/assets/controllers/logo_upload_controller-76cc3774.js

js powercoach.ai collected 2026-10-05 13:59:58 UTC 8,162 bytes, 300 lines download raw bytes

1import { Controller } from "@hotwired/stimulus";
2import { pickPrimarySecondary, rgbToHex } from "utils/color_extraction_utils";
3import { setInnerHTML } from "utils/safe_html";
4
5// Connects to data-controller="logo-upload"
6// Handles logo upload with cropper.js, including color extraction for forms
7export default class extends Controller {
8	static targets = [
9		"modal",
10		"input",
11		"cropperContainer",
12		"cropperInstructions",
13		"cropAndUploadBtn",
14	];
15
16	static values = {
17		modelPrefix: String,
18		placeholderUrl: String,
19		previewId: String,
20		previewImageId: String,
21		removeButtonId: String,
22		logoDataFieldName: String,
23	};
24
25	connect() {
26		this.cropper = null;
27		this.setupEventListeners();
28		this.cacheExternalElements();
29	}
30
31	cacheExternalElements() {
32		// These elements are outside the controller scope (in the parent form)
33		// so we need to look them up by ID/name
34		this.previewElement = document.getElementById(this.previewIdValue);
35		this.previewImageElement = document.getElementById(this.previewImageIdValue);
36		this.removeButtonElement = document.getElementById(this.removeButtonIdValue);
37		this.logoDataFieldElement = document.querySelector(
38			`input[name="${this.logoDataFieldNameValue}"]`
39		);
40	}
41
42	disconnect() {
43		if (this.cropper) {
44			this.cropper.destroy();
45			this.cropper = null;
46		}
47	}
48
49	setupEventListeners() {
50		// Setup modal hidden event to cleanup cropper
51		if (this.hasModalTarget) {
52			this.modalTarget.addEventListener(
53				"hidden.bs.modal",
54				this.handleModalHidden.bind(this)
55			);
56		}
57	}
58
59	handleFileSelect(event) {
60		const file = event.target.files[0];
61		if (!file) return;
62
63		if (file.size > 5 * 1024 * 1024) {
64			alert("File is too large. Maximum size is 5MB.");
65			this.inputTarget.value = "";
66			return;
67		}
68
69		const reader = new FileReader();
70		reader.onload = (e) => {
71			this.initializeCropper(e.target.result);
72		};
73		reader.readAsDataURL(file);
74	}
75
76	initializeCropper(imageSrc) {
77		// Clear previous cropper
78		if (this.cropper) {
79			this.cropper.destroy();
80			setInnerHTML(this.cropperContainerTarget, "");
81		}
82
83		// Show the cropper container
84		this.cropperContainerTarget.parentElement.classList.remove("d-none");
85
86		// Create new image element
87		const img = document.createElement("img");
88		img.src = imageSrc;
89		this.cropperContainerTarget.appendChild(img);
90
91		// Wait for Cropper to be available
92		if (typeof Cropper === "undefined") {
93			setTimeout(() => this.initializeCropper(imageSrc), 100);
94			return;
95		}
96
97		// Initialize Cropper
98		this.cropper = new Cropper(img, {
99			aspectRatio: 1,
100			viewMode: 1,
101			dragMode: "move",
102			autoCropArea: 1,
103			restore: false,
104			guides: true,
105			center: true,
106			highlight: false,
107			cropBoxMovable: false,
108			cropBoxResizable: false,
109			toggleDragModeOnDblclick: false,
110			ready: () => {
111				this.cropperInstructionsTarget.classList.remove("d-none");
112				this.cropAndUploadBtnTarget.disabled = false;
113
114				// Extract colors after the image is fully loaded and cropper is ready
115				const colors = this.extractColorsFromImage(img);
116				this.updateColors(colors);
117			},
118		});
119	}
120
121	handleModalHidden() {
122		this.cropperInstructionsTarget.classList.add("d-none");
123		this.cropperContainerTarget.parentElement.classList.add("d-none");
124		this.inputTarget.value = "";
125		this.cropAndUploadBtnTarget.disabled = true;
126		if (this.cropper) {
127			this.cropper.destroy();
128			setInnerHTML(this.cropperContainerTarget, "");
129			this.cropper = null;
130		}
131	}
132
133	cropAndUpload() {
134		if (!this.cropper) return;
135
136		try {
137			const canvas = this.cropper.getCroppedCanvas({
138				width: 256,
139				height: 256,
140				fillColor: "#fff",
141			});
142
143			if (!canvas) {
144				throw new Error("Failed to create cropped canvas");
145			}
146
147			canvas.toBlob(
148				(blob) => {
149					if (!blob) {
150						throw new Error("Failed to create blob from canvas");
151					}
152
153					const reader = new FileReader();
154					reader.onload = (e) => {
155						// Store the base64 data in the hidden field
156						if (this.logoDataFieldElement) {
157							this.logoDataFieldElement.value = e.target.result;
158						}
159
160						// Update preview
161						if (this.previewImageElement) {
162							this.previewImageElement.src = e.target.result;
163						}
164						if (this.previewElement) {
165							this.previewElement.classList.remove("d-none");
166						}
167						if (this.removeButtonElement) {
168							this.removeButtonElement.classList.remove("d-none");
169						}
170
171						// Close modal
172						this.closeModal();
173					};
174					reader.readAsDataURL(blob);
175				},
176				"image/jpeg",
177				0.8
178			);
179		} catch (error) {
180			console.error("Error processing image:", error);
181			alert("Error processing image. Please try again.");
182		}
183	}
184
185	closeModal() {
186		if (this.hasModalTarget) {
187			// Try direct click on close button first as it's most reliable
188			const closeButton = this.modalTarget.querySelector(".btn-close");
189			if (closeButton) {
190				closeButton.click();
191			} else if (window.bootstrap) {
192				// Fallbacks if close button not found
193				try {
194					const bsModal = window.bootstrap.Modal.getOrCreateInstance(
195						this.modalTarget
196					);
197					if (bsModal) {
198						bsModal.hide();
199					}
200				} catch (e) {
201					console.warn("Could not close modal with Bootstrap API:", e);
202				}
203			}
204		}
205	}
206
207	// Color extraction from logo
208	extractColorsFromImage(imageElement) {
209		try {
210			const canvas = document.createElement("canvas");
211			const ctx = canvas.getContext("2d");
212
213			const width = imageElement.naturalWidth || imageElement.width;
214			const height = imageElement.naturalHeight || imageElement.height;
215
216			if (!width || !height) {
217				console.error("Image has no dimensions, cannot extract colors");
218				return { primary: "#000000", secondary: "#FFFFFF" };
219			}
220
221			canvas.width = width;
222			canvas.height = height;
223			ctx.drawImage(imageElement, 0, 0, width, height);
224
225			const imageData = ctx.getImageData(0, 0, width, height);
226			const data = imageData.data;
227
228			// Build frequency map by sampling every 10th pixel
229			const colorMap = new Map();
230			for (let i = 0; i < data.length; i += 40) {
231				const r = data[i];
232				const g = data[i + 1];
233				const b = data[i + 2];
234				const a = data[i + 3];
235				if (a === 0) continue;
236
237				const color = rgbToHex(r, g, b);
238				colorMap.set(color, (colorMap.get(color) || 0) + 1);
239			}
240
241			const sortedColors = Array.from(colorMap.entries())
242				.sort((a, b) => b[1] - a[1])
243				.map(([color]) => color);
244
245			return pickPrimarySecondary(sortedColors);
246		} catch (error) {
247			console.error("Error extracting colors:", error);
248			return { primary: "#000000", secondary: "#FFFFFF" };
249		}
250	}
251
252	// Update color fields based on the model type
253	updateColors(colors) {
254		// Determine the model from the logo data field name
255		const modelPrefix = this.modelPrefixValue;
256		const isPrimaryColorField =
257			modelPrefix === "game" ? "opponent_primary_color" : "primary_color";
258		const isSecondaryColorField =
259			modelPrefix === "game" ? "opponent_secondary_color" : "secondary_color";
260
261		const primaryColorInput = document.querySelector(
262			`input[name="${modelPrefix}[${isPrimaryColorField}]"]`
263		);
264		const secondaryColorInput = document.querySelector(
265			`input[name="${modelPrefix}[${isSecondaryColorField}]"]`
266		);
267
268		if (primaryColorInput) {
269			primaryColorInput.value = colors.primary;
270			const primaryColorText = primaryColorInput
271				.closest(".input-group")
272				?.querySelector(".input-group-text");
273			if (primaryColorText) {
274				primaryColorText.textContent = colors.primary;
275			}
276
277			// Update abbreviation preview if exists
278			const abbreviationPreview = document.getElementById("abbreviationPreview");
279			if (abbreviationPreview) {
280				abbreviationPreview.style.backgroundColor = colors.primary;
281			}
282		}
283
284		if (secondaryColorInput) {
285			secondaryColorInput.value = colors.secondary;
286			const secondaryColorText = secondaryColorInput
287				.closest(".input-group")
288				?.querySelector(".input-group-text");
289			if (secondaryColorText) {
290				secondaryColorText.textContent = colors.secondary;
291			}
292
293			// Update abbreviation preview text color if exists
294			const abbreviationPreview = document.getElementById("abbreviationPreview");
295			if (abbreviationPreview) {
296				abbreviationPreview.style.color = colors.secondary;
297			}
298		}
299	}
300}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.