mirror of
https://github.com/beestat/app.git
synced 2026-03-20 08:27:59 -04:00
Rotation
This commit is contained in:
parent
fa8e2e862a
commit
f3877f9103
@ -176,6 +176,11 @@ beestat.component.card.floor_plan_editor.prototype.decorate_drawing_pane_ = func
|
||||
this.floor_plan_.dispose();
|
||||
}
|
||||
|
||||
// Dispose existing compass
|
||||
if (this.compass_ !== undefined) {
|
||||
this.compass_.dispose();
|
||||
}
|
||||
|
||||
// Create and render a new SVG component.
|
||||
this.floor_plan_ = new beestat.component.floor_plan(
|
||||
beestat.setting('visualize.floor_plan_id'),
|
||||
@ -184,6 +189,17 @@ beestat.component.card.floor_plan_editor.prototype.decorate_drawing_pane_ = func
|
||||
|
||||
this.floor_plan_.render(parent);
|
||||
|
||||
// Create and render the compass for setting orientation
|
||||
this.compass_ = new beestat.component.compass(
|
||||
beestat.setting('visualize.floor_plan_id')
|
||||
);
|
||||
this.compass_.render(parent);
|
||||
|
||||
// Update floor plan when rotation changes
|
||||
this.compass_.addEventListener('rotation_change', function() {
|
||||
self.update_floor_plan_();
|
||||
});
|
||||
|
||||
setTimeout(function() {
|
||||
if (parent.getBoundingClientRect().width > 0) {
|
||||
self.floor_plan_.set_width(parent.getBoundingClientRect().width);
|
||||
|
||||
@ -359,29 +359,28 @@ beestat.component.card.three_d.prototype.decorate_drawing_pane_ = function(paren
|
||||
this.update_scene_();
|
||||
this.scene_.render($(parent));
|
||||
|
||||
// Get current time of day
|
||||
const now = moment();
|
||||
const current_hour = now.hour();
|
||||
const current_minute = now.minute();
|
||||
|
||||
if (beestat.setting('visualize.range_type') === 'dynamic') {
|
||||
const sensor_ids = Object.keys(
|
||||
beestat.floor_plan.get_sensor_ids_map(this.floor_plan_id_)
|
||||
);
|
||||
if (
|
||||
beestat.setting('visualize.range_dynamic') === 0 &&
|
||||
sensor_ids.length > 0
|
||||
) {
|
||||
this.date_m_ = this.get_most_recent_time_with_data_();
|
||||
} else {
|
||||
this.date_m_ = moment()
|
||||
.subtract(
|
||||
beestat.setting('visualize.range_dynamic'),
|
||||
'day'
|
||||
)
|
||||
.hour(0)
|
||||
.minute(0)
|
||||
.second(0);
|
||||
}
|
||||
// Set the date, then apply current time of day
|
||||
this.date_m_ = moment()
|
||||
.subtract(
|
||||
beestat.setting('visualize.range_dynamic'),
|
||||
'day'
|
||||
)
|
||||
.hour(current_hour)
|
||||
.minute(current_minute)
|
||||
.second(0);
|
||||
} else {
|
||||
// Set the static date, then apply current time of day
|
||||
this.date_m_ = moment(
|
||||
beestat.setting('visualize.range_static.begin') + ' 00:00:00'
|
||||
);
|
||||
)
|
||||
.hour(current_hour)
|
||||
.minute(current_minute);
|
||||
}
|
||||
|
||||
// Set some defaults on the scene.
|
||||
|
||||
258
js/component/compass.js
Normal file
258
js/component/compass.js
Normal file
@ -0,0 +1,258 @@
|
||||
/**
|
||||
* Interactive compass component for setting floor plan rotation.
|
||||
* Allows users to visually set which direction is "North" for their home.
|
||||
*
|
||||
* @param {number} floor_plan_id The floor plan ID
|
||||
*/
|
||||
beestat.component.compass = function(floor_plan_id) {
|
||||
this.floor_plan_id_ = floor_plan_id;
|
||||
beestat.component.apply(this, arguments);
|
||||
};
|
||||
beestat.extend(beestat.component.compass, beestat.component);
|
||||
|
||||
/**
|
||||
* Render the compass.
|
||||
*
|
||||
* @param {HTMLElement} parent
|
||||
*
|
||||
* @return {beestat.component.compass} This
|
||||
*/
|
||||
beestat.component.compass.prototype.render = function(parent) {
|
||||
const self = this;
|
||||
|
||||
// Container for the compass
|
||||
this.container_ = document.createElement('div');
|
||||
Object.assign(this.container_.style, {
|
||||
'position': 'absolute',
|
||||
'bottom': `${beestat.style.size.gutter}px`,
|
||||
'right': `${beestat.style.size.gutter}px`,
|
||||
'width': '80px',
|
||||
'height': '80px',
|
||||
'user-select': 'none',
|
||||
'cursor': 'grab',
|
||||
'z-index': '10'
|
||||
});
|
||||
this.container_.title = 'Drag to set which direction is North for sun/moon positioning';
|
||||
|
||||
// Create SVG
|
||||
this.svg_ = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
this.svg_.setAttribute('width', '80');
|
||||
this.svg_.setAttribute('height', '80');
|
||||
this.svg_.setAttribute('viewBox', '0 0 80 80');
|
||||
this.container_.appendChild(this.svg_);
|
||||
|
||||
// Background circle
|
||||
const bg_circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||
bg_circle.setAttribute('cx', '40');
|
||||
bg_circle.setAttribute('cy', '40');
|
||||
bg_circle.setAttribute('r', '38');
|
||||
bg_circle.setAttribute('fill', beestat.style.color.bluegray.base);
|
||||
bg_circle.setAttribute('stroke', '#fff');
|
||||
bg_circle.setAttribute('stroke-width', '2');
|
||||
bg_circle.setAttribute('opacity', '0.9');
|
||||
this.svg_.appendChild(bg_circle);
|
||||
|
||||
// Create rotating group for compass rose
|
||||
this.compass_group_ = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
this.compass_group_.setAttribute('transform', 'translate(40, 40)');
|
||||
this.svg_.appendChild(this.compass_group_);
|
||||
|
||||
// Cardinal direction markers
|
||||
const directions = [
|
||||
{label: 'N', angle: 0, color: beestat.style.color.red.base, size: 14},
|
||||
{label: 'E', angle: 90, color: '#fff', size: 10},
|
||||
{label: 'S', angle: 180, color: '#fff', size: 10},
|
||||
{label: 'W', angle: 270, color: '#fff', size: 10}
|
||||
];
|
||||
|
||||
directions.forEach(function(dir) {
|
||||
const angle_rad = (dir.angle - 90) * Math.PI / 180; // -90 to start at top
|
||||
const radius = 28;
|
||||
const x = radius * Math.cos(angle_rad);
|
||||
const y = radius * Math.sin(angle_rad);
|
||||
|
||||
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||
text.setAttribute('x', x);
|
||||
text.setAttribute('y', y);
|
||||
text.setAttribute('text-anchor', 'middle');
|
||||
text.setAttribute('dominant-baseline', 'middle');
|
||||
text.setAttribute('fill', dir.color);
|
||||
text.setAttribute('font-size', dir.size);
|
||||
text.setAttribute('font-weight', dir.label === 'N' ? 'bold' : 'normal');
|
||||
text.setAttribute('pointer-events', 'none');
|
||||
text.textContent = dir.label;
|
||||
self.compass_group_.appendChild(text);
|
||||
});
|
||||
|
||||
// North arrow indicator
|
||||
const arrow = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
arrow.setAttribute('d', 'M 0,-22 L 4,-10 L 0,-12 L -4,-10 Z');
|
||||
arrow.setAttribute('fill', beestat.style.color.red.base);
|
||||
arrow.setAttribute('stroke', '#fff');
|
||||
arrow.setAttribute('stroke-width', '1');
|
||||
arrow.setAttribute('pointer-events', 'none');
|
||||
this.compass_group_.appendChild(arrow);
|
||||
|
||||
// Center dot
|
||||
const center_dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||
center_dot.setAttribute('cx', '0');
|
||||
center_dot.setAttribute('cy', '0');
|
||||
center_dot.setAttribute('r', '3');
|
||||
center_dot.setAttribute('fill', '#fff');
|
||||
center_dot.setAttribute('pointer-events', 'none');
|
||||
this.compass_group_.appendChild(center_dot);
|
||||
|
||||
// Set initial rotation
|
||||
this.update_rotation_(this.get_rotation_());
|
||||
|
||||
// Make it draggable
|
||||
this.set_draggable_();
|
||||
|
||||
parent.appendChild(this.container_);
|
||||
this.rendered_ = true;
|
||||
|
||||
return this;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the current rotation value from the floor plan data.
|
||||
*
|
||||
* @return {number} Rotation in degrees (0-359)
|
||||
*/
|
||||
beestat.component.compass.prototype.get_rotation_ = function() {
|
||||
const floor_plan = beestat.cache.floor_plan[this.floor_plan_id_];
|
||||
return floor_plan.data.appearance?.rotation || 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Set the rotation value in the floor plan data.
|
||||
*
|
||||
* @param {number} degrees Rotation in degrees (0-359)
|
||||
*/
|
||||
beestat.component.compass.prototype.set_rotation_ = function(degrees) {
|
||||
const floor_plan = beestat.cache.floor_plan[this.floor_plan_id_];
|
||||
|
||||
if (floor_plan.data.appearance === undefined) {
|
||||
floor_plan.data.appearance = {};
|
||||
}
|
||||
|
||||
// Normalize to 0-359 and store as integer
|
||||
const normalized = Math.round(((degrees % 360) + 360) % 360);
|
||||
floor_plan.data.appearance.rotation = normalized;
|
||||
|
||||
this.update_rotation_(normalized);
|
||||
|
||||
// Dispatch event so other components know rotation changed
|
||||
this.dispatchEvent('rotation_change', normalized);
|
||||
};
|
||||
|
||||
/**
|
||||
* Update the visual rotation of the compass.
|
||||
*
|
||||
* @param {number} degrees Rotation in degrees
|
||||
*/
|
||||
beestat.component.compass.prototype.update_rotation_ = function(degrees) {
|
||||
if (this.compass_group_) {
|
||||
this.compass_group_.setAttribute(
|
||||
'transform',
|
||||
`translate(40, 40) rotate(${degrees})`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Make the compass draggable to change rotation.
|
||||
*/
|
||||
beestat.component.compass.prototype.set_draggable_ = function() {
|
||||
const self = this;
|
||||
let is_dragging = false;
|
||||
|
||||
const get_angle = function(e) {
|
||||
const rect = self.svg_.getBoundingClientRect();
|
||||
const center_x = rect.left + rect.width / 2;
|
||||
const center_y = rect.top + rect.height / 2;
|
||||
|
||||
const client_x = e.clientX || (e.touches && e.touches[0].clientX);
|
||||
const client_y = e.clientY || (e.touches && e.touches[0].clientY);
|
||||
|
||||
const dx = client_x - center_x;
|
||||
const dy = client_y - center_y;
|
||||
|
||||
// Calculate angle in degrees (0° = North/up, clockwise)
|
||||
let angle = Math.atan2(dy, dx) * 180 / Math.PI + 90;
|
||||
if (angle < 0) {
|
||||
angle += 360;
|
||||
}
|
||||
|
||||
return angle;
|
||||
};
|
||||
|
||||
const handle_start = function(e) {
|
||||
is_dragging = true;
|
||||
self.container_.style.cursor = 'grabbing';
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const handle_move = function(e) {
|
||||
if (!is_dragging) {
|
||||
return;
|
||||
}
|
||||
|
||||
const angle = get_angle(e);
|
||||
self.set_rotation_(angle);
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const handle_end = function(e) {
|
||||
if (is_dragging) {
|
||||
is_dragging = false;
|
||||
self.container_.style.cursor = 'grab';
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
// Store handlers for cleanup
|
||||
this.handlers_ = {
|
||||
'mouse_start': handle_start,
|
||||
'mouse_move': handle_move,
|
||||
'mouse_end': handle_end,
|
||||
'touch_start': handle_start,
|
||||
'touch_move': handle_move,
|
||||
'touch_end': handle_end
|
||||
};
|
||||
|
||||
// Mouse events
|
||||
this.container_.addEventListener('mousedown', this.handlers_.mouse_start);
|
||||
window.addEventListener('mousemove', this.handlers_.mouse_move);
|
||||
window.addEventListener('mouseup', this.handlers_.mouse_end);
|
||||
|
||||
// Touch events
|
||||
this.container_.addEventListener('touchstart', this.handlers_.touch_start);
|
||||
window.addEventListener('touchmove', this.handlers_.touch_move);
|
||||
window.addEventListener('touchend', this.handlers_.touch_end);
|
||||
};
|
||||
|
||||
/**
|
||||
* Dispose of the compass.
|
||||
*/
|
||||
beestat.component.compass.prototype.dispose = function() {
|
||||
// Remove event listeners
|
||||
if (this.handlers_) {
|
||||
if (this.container_) {
|
||||
this.container_.removeEventListener('mousedown', this.handlers_.mouse_start);
|
||||
this.container_.removeEventListener('touchstart', this.handlers_.touch_start);
|
||||
}
|
||||
window.removeEventListener('mousemove', this.handlers_.mouse_move);
|
||||
window.removeEventListener('mouseup', this.handlers_.mouse_end);
|
||||
window.removeEventListener('touchmove', this.handlers_.touch_move);
|
||||
window.removeEventListener('touchend', this.handlers_.touch_end);
|
||||
}
|
||||
|
||||
// Remove from DOM
|
||||
if (this.container_ && this.container_.parentNode) {
|
||||
this.container_.parentNode.removeChild(this.container_);
|
||||
}
|
||||
|
||||
// Mark as not rendered
|
||||
this.rendered_ = false;
|
||||
};
|
||||
@ -79,10 +79,6 @@ beestat.component.modal.update_floor_plan.prototype.decorate_contents_ = functio
|
||||
(new beestat.component.title('Appearance')).render(parent);
|
||||
parent.appendChild($.createElement('p').innerHTML('Customize how your floor plan looks in 3D view.'));
|
||||
|
||||
// Debug: Log current appearance data
|
||||
console.log('Floor plan appearance data:', floor_plan.data.appearance);
|
||||
console.log('State appearance:', self.state_.appearance);
|
||||
|
||||
const appearance_grid = document.createElement('div');
|
||||
Object.assign(appearance_grid.style, {
|
||||
'display': 'grid',
|
||||
@ -93,29 +89,6 @@ beestat.component.modal.update_floor_plan.prototype.decorate_contents_ = functio
|
||||
});
|
||||
parent.appendChild(appearance_grid);
|
||||
|
||||
// Rotation input
|
||||
const rotation_input = new beestat.component.input.text()
|
||||
.set_label('Rotation (°)')
|
||||
.set_width('100%')
|
||||
.set_icon('rotate-right');
|
||||
|
||||
const current_rotation = self.state_.appearance?.rotation !== undefined
|
||||
? self.state_.appearance.rotation
|
||||
: (floor_plan.data.appearance?.rotation || 0);
|
||||
rotation_input.set_value(String(current_rotation));
|
||||
|
||||
rotation_input.addEventListener('change', function() {
|
||||
if (self.state_.appearance === undefined) {
|
||||
self.state_.appearance = {};
|
||||
}
|
||||
const value = parseInt(rotation_input.get_value(), 10);
|
||||
if (!isNaN(value) && value >= 0 && value <= 359) {
|
||||
self.state_.appearance.rotation = value;
|
||||
}
|
||||
});
|
||||
|
||||
rotation_input.render($(appearance_grid));
|
||||
|
||||
// Roof Style dropdown
|
||||
const roof_style_select = new beestat.component.input.select()
|
||||
.set_label('Roof Style')
|
||||
@ -172,7 +145,6 @@ beestat.component.modal.update_floor_plan.prototype.decorate_contents_ = functio
|
||||
const current_roof_color = self.state_.appearance?.roof_color !== undefined
|
||||
? self.state_.appearance.roof_color
|
||||
: (floor_plan.data.appearance?.roof_color || '#3a3a3a');
|
||||
console.log('Setting roof color to:', current_roof_color);
|
||||
roof_color_select.set_value(current_roof_color);
|
||||
|
||||
// Siding Color dropdown
|
||||
@ -209,7 +181,6 @@ beestat.component.modal.update_floor_plan.prototype.decorate_contents_ = functio
|
||||
const current_siding_color = self.state_.appearance?.siding_color !== undefined
|
||||
? self.state_.appearance.siding_color
|
||||
: (floor_plan.data.appearance?.siding_color || '#889aaa');
|
||||
console.log('Setting siding color to:', current_siding_color);
|
||||
siding_color_select.set_value(current_siding_color);
|
||||
|
||||
// Ground Color dropdown
|
||||
@ -243,7 +214,6 @@ beestat.component.modal.update_floor_plan.prototype.decorate_contents_ = functio
|
||||
const current_ground_color = self.state_.appearance?.ground_color !== undefined
|
||||
? self.state_.appearance.ground_color
|
||||
: (floor_plan.data.appearance?.ground_color || '#4a7c3f');
|
||||
console.log('Setting ground color to:', current_ground_color);
|
||||
ground_color_select.set_value(current_ground_color);
|
||||
|
||||
// Address
|
||||
|
||||
@ -533,14 +533,10 @@ beestat.component.scene.prototype.update_celestial_lights_ = function(date, lati
|
||||
|
||||
const js_date = date.toDate();
|
||||
|
||||
// Get user rotation offset (0 = North, clockwise)
|
||||
const rotation_degrees = this.get_appearance_value_('rotation');
|
||||
const rotation_offset = (rotation_degrees * Math.PI) / 180;
|
||||
|
||||
// === SUN ===
|
||||
const sun_position = SunCalc.getPosition(js_date, latitude, longitude);
|
||||
const sun_altitude = sun_position.altitude;
|
||||
const sun_azimuth = sun_position.azimuth + rotation_offset;
|
||||
const sun_azimuth = sun_position.azimuth;
|
||||
|
||||
// Convert spherical coordinates to Cartesian
|
||||
// SunCalc: azimuth 0=south, π/2=west, π/-π=north, -π/2=east
|
||||
@ -565,7 +561,7 @@ beestat.component.scene.prototype.update_celestial_lights_ = function(date, lati
|
||||
// === MOON ===
|
||||
const moon_position = SunCalc.getMoonPosition(js_date, latitude, longitude);
|
||||
const moon_altitude = moon_position.altitude;
|
||||
const moon_azimuth = moon_position.azimuth + rotation_offset;
|
||||
const moon_azimuth = moon_position.azimuth;
|
||||
|
||||
// Get moon illumination (phase)
|
||||
const moon_illumination = SunCalc.getMoonIllumination(js_date);
|
||||
@ -1112,10 +1108,22 @@ beestat.component.scene.prototype.update_debug_ = function() {
|
||||
beestat.component.scene.prototype.add_main_group_ = function() {
|
||||
const bounding_box = beestat.floor_plan.get_bounding_box(this.floor_plan_id_);
|
||||
|
||||
// Main group handles rotation and orientation
|
||||
this.main_group_ = new THREE.Group();
|
||||
this.main_group_.translateX((bounding_box.right + bounding_box.left) / -2);
|
||||
this.main_group_.translateZ((bounding_box.bottom + bounding_box.top) / -2);
|
||||
|
||||
// Apply X rotation to orient the floor plan
|
||||
this.main_group_.rotation.x = Math.PI / 2;
|
||||
|
||||
// Apply user-defined rotation around Z axis (vertical axis after X rotation)
|
||||
const rotation_degrees = this.get_appearance_value_('rotation');
|
||||
this.main_group_.rotation.z = (rotation_degrees * Math.PI) / 180;
|
||||
|
||||
// Content group is offset to center the geometry at the rotation point
|
||||
this.content_group_ = new THREE.Group();
|
||||
this.content_group_.translateX((bounding_box.right + bounding_box.left) / -2);
|
||||
this.content_group_.translateZ((bounding_box.bottom + bounding_box.top) / -2);
|
||||
|
||||
this.main_group_.add(this.content_group_);
|
||||
this.scene_.add(this.main_group_);
|
||||
};
|
||||
|
||||
@ -1129,12 +1137,12 @@ beestat.component.scene.prototype.add_floor_plan_ = function() {
|
||||
this.layers_ = {};
|
||||
|
||||
const walls_layer = new THREE.Group();
|
||||
self.main_group_.add(walls_layer);
|
||||
self.content_group_.add(walls_layer);
|
||||
self.layers_['walls'] = walls_layer;
|
||||
|
||||
floor_plan.data.groups.forEach(function(group) {
|
||||
const layer = new THREE.Group();
|
||||
self.main_group_.add(layer);
|
||||
self.content_group_.add(layer);
|
||||
self.layers_[group.group_id] = layer;
|
||||
group.rooms.forEach(function(room) {
|
||||
self.add_room_(layer, group, room);
|
||||
@ -1343,7 +1351,7 @@ beestat.component.scene.prototype.add_hip_roofs_ = function() {
|
||||
|
||||
// Create layer for roofs
|
||||
const roofs_layer = new THREE.Group();
|
||||
this.main_group_.add(roofs_layer);
|
||||
this.content_group_.add(roofs_layer);
|
||||
this.layers_['roof'] = roofs_layer;
|
||||
|
||||
const roof_pitch = beestat.component.scene.roof_pitch;
|
||||
@ -1518,7 +1526,7 @@ beestat.component.scene.prototype.add_flat_roofs_ = function() {
|
||||
|
||||
// Create layer for roofs
|
||||
const roofs_layer = new THREE.Group();
|
||||
this.main_group_.add(roofs_layer);
|
||||
this.content_group_.add(roofs_layer);
|
||||
this.layers_['roof'] = roofs_layer;
|
||||
|
||||
// Process each exposed area
|
||||
@ -1605,7 +1613,7 @@ beestat.component.scene.prototype.add_roof_outlines_ = function() {
|
||||
|
||||
// Create layer for roof outlines
|
||||
const roof_outlines_layer = new THREE.Group();
|
||||
this.main_group_.add(roof_outlines_layer);
|
||||
this.content_group_.add(roof_outlines_layer);
|
||||
this.layers_['roof_outlines'] = roof_outlines_layer;
|
||||
|
||||
// Render each exposed area as red outline
|
||||
@ -1651,7 +1659,7 @@ beestat.component.scene.prototype.add_roof_skeleton_debug_ = function() {
|
||||
|
||||
// Create layer for skeleton debug lines
|
||||
const skeleton_debug_layer = new THREE.Group();
|
||||
this.main_group_.add(skeleton_debug_layer);
|
||||
this.content_group_.add(skeleton_debug_layer);
|
||||
this.layers_['roof_skeleton_debug'] = skeleton_debug_layer;
|
||||
|
||||
let total_polygons = 0;
|
||||
@ -1808,7 +1816,7 @@ beestat.component.scene.prototype.add_environment_ = function() {
|
||||
];
|
||||
|
||||
const environment_layer = new THREE.Group();
|
||||
this.main_group_.add(environment_layer);
|
||||
this.content_group_.add(environment_layer);
|
||||
this.layers_['environment'] = environment_layer;
|
||||
|
||||
strata.forEach(function(stratum) {
|
||||
|
||||
@ -131,6 +131,7 @@ if($setting->get('environment') === 'dev' || $setting->get('environment') === 'd
|
||||
echo '<script src="/js/component/menu_item.js"></script>' . PHP_EOL;
|
||||
echo '<script src="/js/component/scene.js"></script>' . PHP_EOL;
|
||||
echo '<script src="/js/component/floor_plan.js"></script>' . PHP_EOL;
|
||||
echo '<script src="/js/component/compass.js"></script>' . PHP_EOL;
|
||||
echo '<script src="/js/component/radio_group.js"></script>' . PHP_EOL;
|
||||
echo '<script src="/js/component/modal.js"></script>' . PHP_EOL;
|
||||
echo '<script src="/js/component/modal/runtime_thermostat_summary_custom.js"></script>' . PHP_EOL;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user