1164 lines
52 KiB
JavaScript
1164 lines
52 KiB
JavaScript
/**
|
||
* config.js - A modular configuration component for the SL Transport Departures app
|
||
* Allows changing settings on the fly, such as orientation (portrait/landscape)
|
||
*/
|
||
|
||
class ConfigManager {
|
||
constructor(options = {}) {
|
||
// Default options
|
||
this.options = {
|
||
configButtonId: 'config-button',
|
||
configModalId: 'config-modal',
|
||
saveToLocalStorage: true,
|
||
defaultOrientation: 'normal',
|
||
defaultDarkMode: 'auto', // 'auto', 'on', or 'off'
|
||
...options
|
||
};
|
||
|
||
// Add updateBackgroundPreview function
|
||
this.updateBackgroundPreview = function() {
|
||
const preview = document.getElementById('background-preview');
|
||
const imageUrl = document.getElementById('background-image-url').value;
|
||
|
||
if (preview) {
|
||
if (imageUrl && imageUrl.trim() !== '') {
|
||
preview.innerHTML = `<img src="${imageUrl}" alt="Background preview">`;
|
||
} else {
|
||
preview.innerHTML = '<div class="no-image">No image selected</div>';
|
||
}
|
||
}
|
||
};
|
||
|
||
// Configuration state
|
||
this.config = {
|
||
orientation: this.options.defaultOrientation,
|
||
darkMode: this.options.defaultDarkMode,
|
||
backgroundImage: '',
|
||
backgroundOpacity: 0.3, // Default opacity (30%)
|
||
sites: [
|
||
{
|
||
id: '1411',
|
||
name: 'Ambassaderna',
|
||
enabled: true
|
||
}
|
||
],
|
||
combineSameDirection: true, // Combine departures going in the same direction
|
||
...this.loadConfig() // Load saved config if available
|
||
};
|
||
|
||
// Sync configuration with server
|
||
this.syncConfig();
|
||
|
||
// Create UI elements
|
||
this.createConfigButton();
|
||
this.createConfigModal();
|
||
|
||
// Add keyboard shortcuts for testing
|
||
this.setupKeyboardShortcuts();
|
||
|
||
// Apply initial configuration
|
||
this.applyConfig();
|
||
}
|
||
|
||
/**
|
||
* Create the configuration button (gear icon)
|
||
*/
|
||
createConfigButton() {
|
||
const buttonContainer = document.createElement('div');
|
||
buttonContainer.id = this.options.configButtonId;
|
||
buttonContainer.className = 'config-button';
|
||
buttonContainer.title = 'Settings';
|
||
|
||
buttonContainer.innerHTML = `
|
||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||
<path d="M12 15.5A3.5 3.5 0 0 1 8.5 12 3.5 3.5 0 0 1 12 8.5a3.5 3.5 0 0 1 3.5 3.5 3.5 3.5 0 0 1-3.5 3.5m7.43-2.53c.04-.32.07-.65.07-.97 0-.32-.03-.66-.07-1l2.11-1.63c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.31-.61-.22l-2.49 1c-.52-.39-1.06-.73-1.69-.98l-.37-2.65A.506.506 0 0 0 14 2h-4c-.25 0-.46.18-.5.42l-.37 2.65c-.63.25-1.17.59-1.69.98l-2.49-1c-.22-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64L4.57 11c-.04.34-.07.67-.07 1 0 .33.03.65.07.97l-2.11 1.66c-.19.15-.25.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1.01c.52.4 1.06.74 1.69.99l.37 2.65c.04.24.25.42.5.42h4c.25 0 .46-.18.5-.42l.37-2.65c.63-.26 1.17-.59 1.69-.99l2.49 1.01c.22.08.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.66z" fill="#fff"/>
|
||
</svg>
|
||
`;
|
||
|
||
buttonContainer.addEventListener('click', () => this.toggleConfigModal());
|
||
document.body.appendChild(buttonContainer);
|
||
}
|
||
|
||
/**
|
||
* Create the configuration modal
|
||
*/
|
||
createConfigModal() {
|
||
const modalContainer = document.createElement('div');
|
||
modalContainer.id = this.options.configModalId;
|
||
modalContainer.className = 'config-modal';
|
||
modalContainer.style.display = 'none';
|
||
|
||
modalContainer.innerHTML = `
|
||
<div class="config-modal-content">
|
||
<div class="config-modal-header">
|
||
<h2>Settings</h2>
|
||
<span class="config-modal-close">×</span>
|
||
</div>
|
||
<div class="config-tabs">
|
||
<button class="config-tab active" data-tab="display">Display</button>
|
||
<button class="config-tab" data-tab="appearance">Appearance</button>
|
||
<button class="config-tab" data-tab="content">Content</button>
|
||
<button class="config-tab" data-tab="options">Options</button>
|
||
</div>
|
||
<div class="config-modal-body">
|
||
<!-- Display Tab -->
|
||
<div class="config-tab-content active" id="tab-display">
|
||
<div class="config-option">
|
||
<label for="orientation-select">Screen Orientation:</label>
|
||
<select id="orientation-select">
|
||
<option value="normal" ${this.config.orientation === 'normal' ? 'selected' : ''}>Normal (0°)</option>
|
||
<option value="vertical" ${this.config.orientation === 'vertical' ? 'selected' : ''}>Vertical (90°)</option>
|
||
<option value="upsidedown" ${this.config.orientation === 'upsidedown' ? 'selected' : ''}>Upside Down (180°)</option>
|
||
<option value="vertical-reverse" ${this.config.orientation === 'vertical-reverse' ? 'selected' : ''}>Vertical Reverse (270°)</option>
|
||
<option value="landscape" ${this.config.orientation === 'landscape' ? 'selected' : ''}>Landscape (2-column)</option>
|
||
</select>
|
||
</div>
|
||
<div class="config-option">
|
||
<label for="dark-mode-select">Dark Mode:</label>
|
||
<select id="dark-mode-select">
|
||
<option value="auto" ${this.config.darkMode === 'auto' ? 'selected' : ''}>Automatic (Sunset/Sunrise)</option>
|
||
<option value="on" ${this.config.darkMode === 'on' ? 'selected' : ''}>Always On</option>
|
||
<option value="off" ${this.config.darkMode === 'off' ? 'selected' : ''}>Always Off</option>
|
||
</select>
|
||
<div class="sun-times" id="sun-times">
|
||
<small>Sunrise: <span id="sunrise-time">--:--</span> | Sunset: <span id="sunset-time">--:--</span></small>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Appearance Tab -->
|
||
<div class="config-tab-content" id="tab-appearance">
|
||
<div class="config-option">
|
||
<label for="background-image-url">Background Image:</label>
|
||
<input type="text" id="background-image-url" placeholder="Enter image URL" value="${this.config.backgroundImage}">
|
||
<div style="display: flex; gap: 10px; margin-top: 5px;">
|
||
<button id="test-image-button" style="padding: 5px 10px;">Use Test Image</button>
|
||
<label for="local-image-input" style="padding: 5px 10px; background-color: #ddd; border-radius: 4px; cursor: pointer;">
|
||
Select Local Image
|
||
</label>
|
||
<input type="file" id="local-image-input" accept="image/*" style="display: none;">
|
||
</div>
|
||
<div class="background-preview" id="background-preview">
|
||
${this.config.backgroundImage ? `<img src="${this.config.backgroundImage}" alt="Background preview">` : '<div class="no-image">No image selected</div>'}
|
||
</div>
|
||
</div>
|
||
<div class="config-option">
|
||
<label for="background-opacity">Background Opacity: <span id="opacity-value">${Math.round(this.config.backgroundOpacity * 100)}%</span></label>
|
||
<input type="range" id="background-opacity" min="0" max="1" step="0.05" value="${this.config.backgroundOpacity}">
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Content Tab -->
|
||
<div class="config-tab-content" id="tab-content">
|
||
<div class="config-option">
|
||
<label>Transit Sites:</label>
|
||
<div style="margin-bottom: 15px;">
|
||
<div style="display: flex; gap: 10px; margin-bottom: 10px;">
|
||
<input type="text" id="site-search-input" placeholder="Search for transit stop..." style="flex: 1; padding: 8px; border: 1px solid #ddd; border-radius: 4px;">
|
||
<button id="search-site-button" style="padding: 8px 15px; background-color: #0061a1; color: white; border: none; border-radius: 4px; cursor: pointer;">Search</button>
|
||
<button id="select-from-map-button" style="padding: 8px 15px; background-color: #28a745; color: white; border: none; border-radius: 4px; cursor: pointer;">Select from Map</button>
|
||
</div>
|
||
<div id="site-search-results" style="display: none; max-height: 200px; overflow-y: auto; border: 1px solid #ddd; border-radius: 4px; background: white; margin-top: 5px;"></div>
|
||
</div>
|
||
<div id="sites-container">
|
||
${this.generateSitesHTML()}
|
||
</div>
|
||
<div style="margin-top: 10px;">
|
||
<button id="add-site-button" style="padding: 5px 10px;">Add Site Manually</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Options Tab -->
|
||
<div class="config-tab-content" id="tab-options">
|
||
<div class="config-option">
|
||
<label for="combine-directions">
|
||
<input type="checkbox" id="combine-directions" ${this.config.combineSameDirection ? 'checked' : ''}>
|
||
Combine departures going in the same direction
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="config-modal-footer">
|
||
<button id="config-save-button">Save</button>
|
||
<button id="config-cancel-button">Cancel</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(modalContainer);
|
||
|
||
// Add tab switching functionality
|
||
this.setupTabs(modalContainer);
|
||
|
||
// Add event listeners
|
||
modalContainer.querySelector('.config-modal-close').addEventListener('click', () => this.hideConfigModal());
|
||
modalContainer.querySelector('#config-cancel-button').addEventListener('click', () => this.hideConfigModal());
|
||
modalContainer.querySelector('#config-save-button').addEventListener('click', () => this.saveAndApplyConfig());
|
||
modalContainer.querySelector('#test-image-button').addEventListener('click', () => {
|
||
const testImageUrl = 'https://images.unsplash.com/photo-1509356843151-3e7d96241e11?q=80&w=1000';
|
||
document.getElementById('background-image-url').value = testImageUrl;
|
||
this.updateBackgroundPreview();
|
||
});
|
||
|
||
// Add event listener for add site button
|
||
const addSiteButton = modalContainer.querySelector('#add-site-button');
|
||
if (addSiteButton) {
|
||
addSiteButton.addEventListener('click', () => this.addNewSite());
|
||
}
|
||
|
||
// Add event listeners for site search
|
||
const searchSiteButton = modalContainer.querySelector('#search-site-button');
|
||
const siteSearchInput = modalContainer.querySelector('#site-search-input');
|
||
if (searchSiteButton && siteSearchInput) {
|
||
searchSiteButton.addEventListener('click', () => this.searchSites());
|
||
siteSearchInput.addEventListener('keypress', (e) => {
|
||
if (e.key === 'Enter') {
|
||
this.searchSites();
|
||
}
|
||
});
|
||
}
|
||
|
||
// Add event listener for map selection
|
||
const selectFromMapButton = modalContainer.querySelector('#select-from-map-button');
|
||
if (selectFromMapButton) {
|
||
selectFromMapButton.addEventListener('click', () => this.showMapSelector());
|
||
}
|
||
|
||
// Add event listener for local image selection
|
||
const localImageInput = modalContainer.querySelector('#local-image-input');
|
||
if (localImageInput) {
|
||
localImageInput.addEventListener('change', (event) => {
|
||
const file = event.target.files[0];
|
||
if (file) {
|
||
const reader = new FileReader();
|
||
reader.onload = (e) => {
|
||
const imageDataUrl = e.target.result;
|
||
document.getElementById('background-image-url').value = imageDataUrl;
|
||
this.updateBackgroundPreview();
|
||
};
|
||
reader.readAsDataURL(file);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Close modal when clicking outside the content
|
||
modalContainer.addEventListener('click', (event) => {
|
||
if (event.target === modalContainer) {
|
||
this.hideConfigModal();
|
||
}
|
||
});
|
||
|
||
// Add event listeners for site management
|
||
this.setupSiteEventListeners();
|
||
}
|
||
|
||
/**
|
||
* Setup event listeners for site management
|
||
*/
|
||
setupSiteEventListeners() {
|
||
const sitesContainer = document.getElementById('sites-container');
|
||
if (!sitesContainer) return;
|
||
|
||
const removeButtons = sitesContainer.querySelectorAll('.remove-site-button');
|
||
removeButtons.forEach(button => {
|
||
button.addEventListener('click', (event) => {
|
||
const siteItem = event.target.closest('.site-item');
|
||
if (siteItem) {
|
||
const index = parseInt(siteItem.dataset.index);
|
||
this.removeSite(index);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Add a new site to the configuration
|
||
*/
|
||
addNewSite() {
|
||
this.config.sites.push({
|
||
id: '',
|
||
name: 'New Site',
|
||
enabled: true
|
||
});
|
||
|
||
const sitesContainer = document.getElementById('sites-container');
|
||
if (sitesContainer) {
|
||
sitesContainer.innerHTML = this.generateSitesHTML();
|
||
this.setupSiteEventListeners();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Remove a site from the configuration
|
||
*/
|
||
removeSite(index) {
|
||
if (index >= 0 && index < this.config.sites.length) {
|
||
this.config.sites.splice(index, 1);
|
||
|
||
const sitesContainer = document.getElementById('sites-container');
|
||
if (sitesContainer) {
|
||
sitesContainer.innerHTML = this.generateSitesHTML();
|
||
this.setupSiteEventListeners();
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Setup tab switching functionality
|
||
*/
|
||
setupTabs(modalContainer) {
|
||
const tabs = modalContainer.querySelectorAll('.config-tab');
|
||
const tabContents = modalContainer.querySelectorAll('.config-tab-content');
|
||
|
||
tabs.forEach(tab => {
|
||
tab.addEventListener('click', () => {
|
||
const targetTab = tab.dataset.tab;
|
||
|
||
// Remove active class from all tabs and contents
|
||
tabs.forEach(t => t.classList.remove('active'));
|
||
tabContents.forEach(content => content.classList.remove('active'));
|
||
|
||
// Add active class to clicked tab and corresponding content
|
||
tab.classList.add('active');
|
||
const targetContent = modalContainer.querySelector(`#tab-${targetTab}`);
|
||
if (targetContent) {
|
||
targetContent.classList.add('active');
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Toggle the configuration modal visibility
|
||
*/
|
||
toggleConfigModal() {
|
||
const modal = document.getElementById(this.options.configModalId);
|
||
if (modal.style.display === 'none') {
|
||
this.showConfigModal();
|
||
} else {
|
||
this.hideConfigModal();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Hide the configuration modal
|
||
*/
|
||
hideConfigModal() {
|
||
const modal = document.getElementById(this.options.configModalId);
|
||
modal.style.display = 'none';
|
||
}
|
||
|
||
/**
|
||
* Show the configuration modal
|
||
*/
|
||
showConfigModal() {
|
||
const modal = document.getElementById(this.options.configModalId);
|
||
modal.style.display = 'flex';
|
||
|
||
// Reset to first tab
|
||
const tabs = modal.querySelectorAll('.config-tab');
|
||
const tabContents = modal.querySelectorAll('.config-tab-content');
|
||
tabs.forEach(t => t.classList.remove('active'));
|
||
tabContents.forEach(content => content.classList.remove('active'));
|
||
if (tabs.length > 0) {
|
||
tabs[0].classList.add('active');
|
||
}
|
||
if (tabContents.length > 0) {
|
||
tabContents[0].classList.add('active');
|
||
}
|
||
|
||
// Update form values to match current config
|
||
document.getElementById('orientation-select').value = this.config.orientation;
|
||
document.getElementById('dark-mode-select').value = this.config.darkMode;
|
||
document.getElementById('combine-directions').checked = this.config.combineSameDirection;
|
||
|
||
// Update sites container
|
||
const sitesContainer = document.getElementById('sites-container');
|
||
if (sitesContainer) {
|
||
sitesContainer.innerHTML = this.generateSitesHTML();
|
||
this.setupSiteEventListeners();
|
||
}
|
||
|
||
// Update background image and opacity values
|
||
const backgroundImageUrl = document.getElementById('background-image-url');
|
||
const backgroundOpacity = document.getElementById('background-opacity');
|
||
const opacityValue = document.getElementById('opacity-value');
|
||
|
||
if (backgroundImageUrl) {
|
||
backgroundImageUrl.value = this.config.backgroundImage || '';
|
||
}
|
||
|
||
if (backgroundOpacity) {
|
||
backgroundOpacity.value = this.config.backgroundOpacity;
|
||
}
|
||
|
||
if (opacityValue) {
|
||
opacityValue.textContent = `${Math.round(this.config.backgroundOpacity * 100)}%`;
|
||
}
|
||
|
||
// Update background preview
|
||
this.updateBackgroundPreview();
|
||
|
||
// Update sun times display
|
||
this.updateSunTimesDisplay();
|
||
|
||
// Add event listeners
|
||
this.setupModalEventListeners();
|
||
}
|
||
|
||
/**
|
||
* Setup event listeners for the modal
|
||
*/
|
||
setupModalEventListeners() {
|
||
// Background image URL input
|
||
const imageUrlInput = document.getElementById('background-image-url');
|
||
if (imageUrlInput) {
|
||
const newInput = imageUrlInput.cloneNode(true);
|
||
imageUrlInput.parentNode.replaceChild(newInput, imageUrlInput);
|
||
newInput.addEventListener('input', () => this.updateBackgroundPreview());
|
||
}
|
||
|
||
// Opacity slider
|
||
const opacitySlider = document.getElementById('background-opacity');
|
||
if (opacitySlider) {
|
||
const newSlider = opacitySlider.cloneNode(true);
|
||
opacitySlider.parentNode.replaceChild(newSlider, opacitySlider);
|
||
newSlider.addEventListener('input', (e) => {
|
||
document.getElementById('opacity-value').textContent = `${Math.round(e.target.value * 100)}%`;
|
||
});
|
||
}
|
||
|
||
// Local image selection
|
||
const localImageInput = document.getElementById('local-image-input');
|
||
if (localImageInput) {
|
||
const newInput = localImageInput.cloneNode(true);
|
||
localImageInput.parentNode.replaceChild(newInput, localImageInput);
|
||
newInput.addEventListener('change', (event) => {
|
||
const file = event.target.files[0];
|
||
if (file) {
|
||
const reader = new FileReader();
|
||
reader.onload = (e) => {
|
||
document.getElementById('background-image-url').value = e.target.result;
|
||
this.updateBackgroundPreview();
|
||
};
|
||
reader.readAsDataURL(file);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Save and apply the configuration from the form
|
||
*/
|
||
saveAndApplyConfig() {
|
||
try {
|
||
// Get values from form
|
||
const orientationSelect = document.getElementById('orientation-select');
|
||
const darkModeSelect = document.getElementById('dark-mode-select');
|
||
const backgroundImageUrl = document.getElementById('background-image-url');
|
||
const backgroundOpacity = document.getElementById('background-opacity');
|
||
const combineDirections = document.getElementById('combine-directions');
|
||
|
||
// Get sites configuration from form
|
||
const sitesConfig = this.getSitesFromForm();
|
||
|
||
// Get the background image URL directly from the DOM
|
||
const imageUrlValue = document.querySelector('#background-image-url').value;
|
||
|
||
// Update config
|
||
this.config.orientation = orientationSelect.value;
|
||
this.config.darkMode = darkModeSelect.value;
|
||
this.config.backgroundImage = imageUrlValue;
|
||
this.config.backgroundOpacity = parseFloat(backgroundOpacity.value);
|
||
this.config.combineSameDirection = combineDirections.checked;
|
||
this.config.sites = sitesConfig;
|
||
|
||
// Save config
|
||
this.saveConfig();
|
||
|
||
// Sync with server
|
||
this.syncConfig();
|
||
|
||
// Apply config
|
||
this.applyConfig();
|
||
|
||
// Hide modal
|
||
this.hideConfigModal();
|
||
} catch (error) {
|
||
console.error('Error saving configuration:', error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Apply the current configuration to the page
|
||
*/
|
||
applyConfig() {
|
||
// Apply orientation
|
||
document.body.classList.remove('normal', 'landscape', 'vertical', 'upsidedown', 'vertical-reverse');
|
||
document.body.classList.add(this.config.orientation);
|
||
|
||
// Apply dark mode setting
|
||
this.applyDarkModeSetting();
|
||
|
||
// Apply background image and opacity
|
||
this.applyBackgroundImage();
|
||
|
||
// Ensure content wrapper exists when changing to rotated modes
|
||
if (['vertical', 'upsidedown', 'vertical-reverse'].includes(this.config.orientation)) {
|
||
this.ensureContentWrapper();
|
||
}
|
||
|
||
// Dispatch event for other components to react to config changes
|
||
const event = new CustomEvent('configChanged', { detail: { config: this.config } });
|
||
document.dispatchEvent(event);
|
||
}
|
||
|
||
/**
|
||
* Apply dark mode setting based on configuration
|
||
*/
|
||
applyDarkModeSetting() {
|
||
// Always update sun times display for informational purposes
|
||
this.updateSunTimesDisplay();
|
||
|
||
// If we have a WeatherManager instance
|
||
if (window.weatherManager) {
|
||
if (this.config.darkMode === 'auto') {
|
||
window.weatherManager.updateDarkModeBasedOnTime();
|
||
} else {
|
||
const isDarkMode = this.config.darkMode === 'on';
|
||
window.weatherManager.setDarkMode(isDarkMode);
|
||
}
|
||
} else {
|
||
const isDarkMode = this.config.darkMode === 'on';
|
||
document.body.classList.toggle('dark-mode', isDarkMode);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Apply background image and opacity settings
|
||
*/
|
||
applyBackgroundImage() {
|
||
// Remove any existing background overlay
|
||
const existingOverlay = document.getElementById('background-overlay');
|
||
if (existingOverlay) {
|
||
existingOverlay.remove();
|
||
}
|
||
|
||
// If there's a background image URL, create and apply the overlay
|
||
if (this.config.backgroundImage && this.config.backgroundImage.trim() !== '') {
|
||
const overlay = document.createElement('div');
|
||
overlay.id = 'background-overlay';
|
||
overlay.style.position = 'fixed';
|
||
overlay.style.top = '0';
|
||
overlay.style.left = '0';
|
||
overlay.style.width = '100vw';
|
||
overlay.style.height = '100vh';
|
||
overlay.style.backgroundImage = `url(${this.config.backgroundImage})`;
|
||
overlay.style.backgroundSize = 'cover';
|
||
overlay.style.backgroundPosition = 'center';
|
||
overlay.style.opacity = this.config.backgroundOpacity;
|
||
overlay.style.zIndex = '-1';
|
||
overlay.style.pointerEvents = 'none';
|
||
|
||
// Adjust background rotation based on orientation
|
||
if (this.config.orientation === 'vertical') {
|
||
overlay.style.transform = 'rotate(90deg) scale(2)';
|
||
overlay.style.transformOrigin = 'center center';
|
||
} else if (this.config.orientation === 'upsidedown') {
|
||
overlay.style.transform = 'rotate(180deg) scale(1.5)';
|
||
overlay.style.transformOrigin = 'center center';
|
||
} else if (this.config.orientation === 'vertical-reverse') {
|
||
overlay.style.transform = 'rotate(270deg) scale(2)';
|
||
overlay.style.transformOrigin = 'center center';
|
||
} else {
|
||
overlay.style.transform = 'scale(1.2)';
|
||
overlay.style.transformOrigin = 'center center';
|
||
}
|
||
|
||
// Insert as the first child of body
|
||
document.body.insertBefore(overlay, document.body.firstChild);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Update the sun times display in the config modal
|
||
*/
|
||
updateSunTimesDisplay() {
|
||
if (window.weatherManager) {
|
||
const sunriseElement = document.getElementById('sunrise-time');
|
||
const sunsetElement = document.getElementById('sunset-time');
|
||
|
||
if (sunriseElement && sunsetElement) {
|
||
const sunriseTime = window.weatherManager.getSunriseTime();
|
||
const sunsetTime = window.weatherManager.getSunsetTime();
|
||
|
||
sunriseElement.textContent = sunriseTime;
|
||
sunsetElement.textContent = sunsetTime;
|
||
|
||
const weatherSunTimesElement = document.querySelector('#custom-weather .sun-times');
|
||
if (weatherSunTimesElement) {
|
||
weatherSunTimesElement.textContent = `Sunrise: ${sunriseTime} | Sunset: ${sunsetTime}`;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Generate HTML for sites configuration
|
||
*/
|
||
generateSitesHTML() {
|
||
if (!this.config.sites || this.config.sites.length === 0) {
|
||
return '<div class="no-sites">No sites configured</div>';
|
||
}
|
||
|
||
return this.config.sites.map((site, index) => `
|
||
<div class="site-item" data-index="${index}">
|
||
<div style="display: flex; align-items: center; margin-bottom: 5px;">
|
||
<input type="checkbox" class="site-enabled" ${site.enabled ? 'checked' : ''}>
|
||
<input type="text" class="site-name" value="${site.name}" placeholder="Site Name" style="flex: 1; margin: 0 5px;">
|
||
<button class="remove-site-button" style="padding: 2px 5px;">×</button>
|
||
</div>
|
||
<div style="display: flex; align-items: center;">
|
||
<span style="margin-right: 5px;">ID:</span>
|
||
<input type="text" class="site-id" value="${site.id}" placeholder="Site ID" style="width: 100px;">
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
/**
|
||
* Search for transit sites
|
||
*/
|
||
async searchSites() {
|
||
const searchInput = document.getElementById('site-search-input');
|
||
const resultsContainer = document.getElementById('site-search-results');
|
||
|
||
if (!searchInput || !resultsContainer) return;
|
||
|
||
const query = searchInput.value.trim();
|
||
if (!query || query.length < 2) {
|
||
resultsContainer.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
try {
|
||
resultsContainer.style.display = 'block';
|
||
resultsContainer.innerHTML = '<div style="padding: 10px; text-align: center; color: #666;">Searching...</div>';
|
||
|
||
const response = await fetch(`/api/sites/search?q=${encodeURIComponent(query)}`);
|
||
|
||
if (!response.ok) {
|
||
const errorData = await response.json().catch(() => ({ error: `HTTP ${response.status}` }));
|
||
throw new Error(errorData.error || `HTTP error! Status: ${response.status}`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
|
||
if (!data.sites || data.sites.length === 0) {
|
||
resultsContainer.innerHTML = '<div style="padding: 10px; text-align: center; color: #666;">No sites found. Try a different search term.</div>';
|
||
return;
|
||
}
|
||
|
||
resultsContainer.innerHTML = data.sites.map(site => `
|
||
<div class="site-search-result" style="padding: 10px; border-bottom: 1px solid #eee; cursor: pointer; transition: background-color 0.2s;"
|
||
data-site-id="${site.id}" data-site-name="${site.name}">
|
||
<div style="font-weight: bold; color: #0061a1;">${site.name}</div>
|
||
<div style="font-size: 0.85em; color: #666;">ID: ${site.id}</div>
|
||
</div>
|
||
`).join('');
|
||
|
||
// Add click handlers to search results
|
||
resultsContainer.querySelectorAll('.site-search-result').forEach(result => {
|
||
result.addEventListener('click', () => {
|
||
const siteId = result.dataset.siteId;
|
||
const siteName = result.dataset.siteName;
|
||
this.addSiteFromSearch(siteId, siteName);
|
||
searchInput.value = '';
|
||
resultsContainer.style.display = 'none';
|
||
});
|
||
|
||
result.addEventListener('mouseenter', () => {
|
||
result.style.backgroundColor = '#f5f5f5';
|
||
});
|
||
|
||
result.addEventListener('mouseleave', () => {
|
||
result.style.backgroundColor = 'white';
|
||
});
|
||
});
|
||
|
||
} catch (error) {
|
||
console.error('Error searching sites:', error);
|
||
let errorMessage = error.message || 'Unknown error';
|
||
|
||
// Provide more helpful error messages
|
||
if (error.message.includes('Failed to fetch') || error.message.includes('NetworkError')) {
|
||
errorMessage = 'Unable to connect to server. Please make sure the server is running.';
|
||
} else if (error.message.includes('HTTP')) {
|
||
errorMessage = `Server error: ${error.message}`;
|
||
}
|
||
|
||
resultsContainer.innerHTML = `<div style="padding: 10px; text-align: center; color: #d32f2f;">Error: ${errorMessage}</div>`;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Add a site from search results
|
||
*/
|
||
addSiteFromSearch(siteId, siteName) {
|
||
// Check if site already exists
|
||
if (this.config.sites.some(site => site.id === siteId)) {
|
||
alert(`Site "${siteName}" (ID: ${siteId}) is already in your list.`);
|
||
return;
|
||
}
|
||
|
||
this.config.sites.push({
|
||
id: siteId,
|
||
name: siteName,
|
||
enabled: true
|
||
});
|
||
|
||
const sitesContainer = document.getElementById('sites-container');
|
||
if (sitesContainer) {
|
||
sitesContainer.innerHTML = this.generateSitesHTML();
|
||
this.setupSiteEventListeners();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Show map selector for choosing transit sites
|
||
*/
|
||
async showMapSelector() {
|
||
// Create map modal
|
||
const mapModal = document.createElement('div');
|
||
mapModal.id = 'map-selector-modal';
|
||
mapModal.className = 'config-modal';
|
||
mapModal.style.display = 'flex';
|
||
|
||
mapModal.innerHTML = `
|
||
<div class="config-modal-content" style="max-width: 90vw; max-height: 90vh; width: 1200px;">
|
||
<div class="config-modal-header">
|
||
<h2>Select Transit Stop from Map</h2>
|
||
<span class="map-modal-close">×</span>
|
||
</div>
|
||
<div style="padding: 20px;">
|
||
<div style="margin-bottom: 15px;">
|
||
<input type="text" id="map-search-input" placeholder="Search area or zoom to location..." style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px;">
|
||
</div>
|
||
<div id="map-container" style="width: 100%; height: 600px; border: 1px solid #ddd; border-radius: 4px;"></div>
|
||
<div style="margin-top: 15px; padding: 10px; background-color: #f0f0f0; border-radius: 4px;">
|
||
<strong>Instructions:</strong> Click on a marker to select a transit stop. Zoom and pan to explore the map.
|
||
</div>
|
||
</div>
|
||
<div class="config-modal-footer">
|
||
<button id="map-close-button">Close</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(mapModal);
|
||
|
||
// Wait for DOM to be ready
|
||
await new Promise(resolve => setTimeout(resolve, 100));
|
||
|
||
// Initialize map centered on Stockholm
|
||
const map = L.map('map-container').setView([59.3293, 18.0686], 13);
|
||
|
||
// Add OpenStreetMap tiles
|
||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||
attribution: '© OpenStreetMap contributors',
|
||
maxZoom: 19
|
||
}).addTo(map);
|
||
|
||
// Close handlers
|
||
const closeMap = () => {
|
||
map.remove();
|
||
document.body.removeChild(mapModal);
|
||
};
|
||
|
||
mapModal.querySelector('.map-modal-close').addEventListener('click', closeMap);
|
||
mapModal.querySelector('#map-close-button').addEventListener('click', closeMap);
|
||
mapModal.addEventListener('click', (e) => {
|
||
if (e.target === mapModal) closeMap();
|
||
});
|
||
|
||
// Load transit stops - search for common Stockholm areas
|
||
const loadSitesOnMap = async () => {
|
||
try {
|
||
// Start with focused search to avoid too many markers
|
||
const searchTerms = ['Ambassaderna'];
|
||
const allSites = new Map();
|
||
|
||
for (const term of searchTerms) {
|
||
try {
|
||
const response = await fetch(`/api/sites/search?q=${encodeURIComponent(term)}`);
|
||
const data = await response.json();
|
||
console.log(`Search "${term}" returned:`, data);
|
||
if (data.sites) {
|
||
data.sites.forEach(site => {
|
||
// Only add sites with valid coordinates from API
|
||
const lat = site.lat || site.latitude;
|
||
const lon = site.lon || site.longitude;
|
||
|
||
if (lat && lon && !isNaN(parseFloat(lat)) && !isNaN(parseFloat(lon))) {
|
||
if (!allSites.has(site.id)) {
|
||
allSites.set(site.id, {
|
||
id: site.id,
|
||
name: site.name,
|
||
lat: parseFloat(lat),
|
||
lon: parseFloat(lon)
|
||
});
|
||
}
|
||
} else {
|
||
console.log(`Site ${site.id} (${site.name}) missing coordinates, skipping`);
|
||
}
|
||
});
|
||
}
|
||
} catch (err) {
|
||
console.error(`Error searching for ${term}:`, err);
|
||
}
|
||
}
|
||
|
||
// Add known site with coordinates as fallback
|
||
const knownSites = [
|
||
{ id: '1411', name: 'Ambassaderna', lat: 59.3293, lon: 18.0686 }
|
||
];
|
||
|
||
knownSites.forEach(site => {
|
||
if (!allSites.has(site.id)) {
|
||
allSites.set(site.id, site);
|
||
}
|
||
});
|
||
|
||
const sitesArray = Array.from(allSites.values());
|
||
console.log(`Loading ${sitesArray.length} sites on map with coordinates:`, sitesArray);
|
||
|
||
if (sitesArray.length > 0) {
|
||
const markers = [];
|
||
sitesArray.forEach(site => {
|
||
const lat = site.lat;
|
||
const lon = site.lon;
|
||
|
||
if (!lat || !lon || isNaN(lat) || isNaN(lon)) {
|
||
console.warn(`Invalid coordinates for site ${site.id}, skipping`);
|
||
return;
|
||
}
|
||
|
||
// Create custom icon
|
||
const customIcon = L.divIcon({
|
||
className: 'custom-marker',
|
||
html: `<div style="background-color: #0061a1; color: white; border-radius: 50%; width: 30px; height: 30px; display: flex; align-items: center; justify-content: center; font-weight: bold; border: 3px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.3);">🚌</div>`,
|
||
iconSize: [30, 30],
|
||
iconAnchor: [15, 15]
|
||
});
|
||
|
||
const marker = L.marker([lat, lon], { icon: customIcon }).addTo(map);
|
||
|
||
const popupContent = `
|
||
<div style="min-width: 200px;">
|
||
<strong style="font-size: 1.1em; color: #0061a1;">${site.name}</strong><br>
|
||
<span style="color: #666; font-size: 0.9em;">ID: ${site.id}</span><br>
|
||
<button class="select-site-from-map" data-site-id="${site.id}" data-site-name="${site.name}"
|
||
style="margin-top: 8px; padding: 8px 15px; width: 100%; background-color: #0061a1; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold;">
|
||
Select This Stop
|
||
</button>
|
||
</div>
|
||
`;
|
||
|
||
marker.bindPopup(popupContent);
|
||
markers.push(marker);
|
||
|
||
// Make marker clickable to open popup
|
||
marker.on('click', function() {
|
||
this.openPopup();
|
||
});
|
||
});
|
||
|
||
// Fit map to show all markers, or center on first marker
|
||
if (markers.length > 0) {
|
||
if (markers.length === 1) {
|
||
map.setView(markers[0].getLatLng(), 15);
|
||
} else {
|
||
const group = new L.featureGroup(markers);
|
||
map.fitBounds(group.getBounds().pad(0.1));
|
||
}
|
||
} else {
|
||
// If no markers, show message
|
||
console.log('No sites with coordinates found');
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading sites on map:', error);
|
||
}
|
||
};
|
||
|
||
// Load sites after map is initialized
|
||
setTimeout(() => {
|
||
loadSitesOnMap();
|
||
}, 500);
|
||
|
||
// Handle site selection from map popup - use event delegation on the modal
|
||
mapModal.addEventListener('click', (e) => {
|
||
if (e.target.classList.contains('select-site-from-map')) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const siteId = e.target.dataset.siteId;
|
||
const siteName = e.target.dataset.siteName;
|
||
if (siteId && siteName) {
|
||
console.log(`Selecting site: ${siteName} (ID: ${siteId})`);
|
||
this.addSiteFromSearch(siteId, siteName);
|
||
closeMap();
|
||
}
|
||
}
|
||
});
|
||
|
||
// Also handle popupopen event to ensure buttons are clickable
|
||
map.on('popupopen', (e) => {
|
||
const popup = e.popup;
|
||
const content = popup.getElement();
|
||
if (content) {
|
||
const selectBtn = content.querySelector('.select-site-from-map');
|
||
if (selectBtn) {
|
||
selectBtn.addEventListener('click', (ev) => {
|
||
ev.preventDefault();
|
||
ev.stopPropagation();
|
||
const siteId = selectBtn.dataset.siteId;
|
||
const siteName = selectBtn.dataset.siteName;
|
||
if (siteId && siteName) {
|
||
console.log(`Selecting site from popup: ${siteName} (ID: ${siteId})`);
|
||
this.addSiteFromSearch(siteId, siteName);
|
||
closeMap();
|
||
}
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
// Map search functionality
|
||
const mapSearchInput = document.getElementById('map-search-input');
|
||
if (mapSearchInput) {
|
||
mapSearchInput.addEventListener('keypress', async (e) => {
|
||
if (e.key === 'Enter') {
|
||
const query = mapSearchInput.value.trim();
|
||
if (query.length < 2) return;
|
||
|
||
try {
|
||
const response = await fetch(`/api/sites/search?q=${encodeURIComponent(query)}`);
|
||
const data = await response.json();
|
||
|
||
if (data.sites && data.sites.length > 0) {
|
||
// Clear existing markers
|
||
map.eachLayer((layer) => {
|
||
if (layer instanceof L.Marker) {
|
||
map.removeLayer(layer);
|
||
}
|
||
});
|
||
|
||
// Clear existing markers
|
||
map.eachLayer((layer) => {
|
||
if (layer instanceof L.Marker) {
|
||
map.removeLayer(layer);
|
||
}
|
||
});
|
||
|
||
// Add markers for search results
|
||
const markers = [];
|
||
data.sites.forEach(site => {
|
||
let lat = site.lat || site.latitude;
|
||
let lon = site.lon || site.longitude;
|
||
|
||
// If no coordinates, use approximate location based on map center
|
||
if (!lat || !lon) {
|
||
const center = map.getCenter();
|
||
lat = center.lat + (Math.random() - 0.5) * 0.05;
|
||
lon = center.lon + (Math.random() - 0.5) * 0.05;
|
||
}
|
||
|
||
// Create custom icon
|
||
const customIcon = L.divIcon({
|
||
className: 'custom-transit-marker',
|
||
html: `<div style="background-color: #0061a1; color: white; border-radius: 50%; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; font-weight: bold; border: 3px solid white; box-shadow: 0 2px 8px rgba(0,0,0,0.4); font-size: 18px;">🚌</div>`,
|
||
iconSize: [32, 32],
|
||
iconAnchor: [16, 16],
|
||
popupAnchor: [0, -16]
|
||
});
|
||
|
||
const marker = L.marker([lat, lon], {
|
||
icon: customIcon,
|
||
title: site.name
|
||
}).addTo(map);
|
||
|
||
const popupContent = document.createElement('div');
|
||
popupContent.style.minWidth = '220px';
|
||
popupContent.innerHTML = `
|
||
<div style="margin-bottom: 8px;">
|
||
<strong style="font-size: 1.1em; color: #0061a1; display: block; margin-bottom: 4px;">${site.name}</strong>
|
||
<span style="color: #666; font-size: 0.9em;">ID: ${site.id}</span>
|
||
</div>
|
||
<button class="select-site-from-map-btn"
|
||
data-site-id="${site.id}"
|
||
data-site-name="${site.name}"
|
||
style="margin-top: 8px; padding: 10px 15px; width: 100%; background-color: #0061a1; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; font-size: 1em;">
|
||
Select This Stop
|
||
</button>
|
||
`;
|
||
|
||
marker.bindPopup(popupContent);
|
||
markers.push(marker);
|
||
|
||
marker.on('click', function() {
|
||
this.openPopup();
|
||
});
|
||
});
|
||
|
||
// Fit map to show results
|
||
if (markers.length > 0) {
|
||
const group = new L.featureGroup(markers);
|
||
map.fitBounds(group.getBounds().pad(0.1));
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Error searching on map:', error);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get sites configuration from form
|
||
*/
|
||
getSitesFromForm() {
|
||
const sitesContainer = document.getElementById('sites-container');
|
||
const siteItems = sitesContainer.querySelectorAll('.site-item');
|
||
|
||
return Array.from(siteItems).map(item => {
|
||
return {
|
||
id: item.querySelector('.site-id').value,
|
||
name: item.querySelector('.site-name').value,
|
||
enabled: item.querySelector('.site-enabled').checked
|
||
};
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Sync configuration with server
|
||
*/
|
||
syncConfig() {
|
||
fetch('/api/config/update', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify(this.config)
|
||
})
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
console.log('Configuration synced with server:', data);
|
||
})
|
||
.catch(error => {
|
||
console.error('Error syncing configuration with server:', error);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Save the configuration to localStorage
|
||
*/
|
||
saveConfig() {
|
||
if (this.options.saveToLocalStorage && window.localStorage) {
|
||
try {
|
||
localStorage.setItem('sl-departures-config', JSON.stringify(this.config));
|
||
console.log('Configuration saved to localStorage');
|
||
} catch (error) {
|
||
console.error('Error saving configuration to localStorage:', error);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Load the configuration from localStorage
|
||
*/
|
||
loadConfig() {
|
||
if (this.options.saveToLocalStorage && window.localStorage) {
|
||
try {
|
||
const savedConfig = localStorage.getItem('sl-departures-config');
|
||
if (savedConfig) {
|
||
console.log('Configuration loaded from localStorage');
|
||
return JSON.parse(savedConfig);
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading configuration from localStorage:', error);
|
||
}
|
||
}
|
||
return {};
|
||
}
|
||
|
||
/**
|
||
* Setup keyboard shortcuts for testing
|
||
*/
|
||
setupKeyboardShortcuts() {
|
||
document.addEventListener('keydown', (event) => {
|
||
// Only handle keyboard shortcuts if not in an input field
|
||
if (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA') {
|
||
return;
|
||
}
|
||
|
||
// Ctrl/Cmd + Shift + O: Toggle orientation
|
||
if (event.key === 'O' && (event.ctrlKey || event.metaKey) && event.shiftKey) {
|
||
event.preventDefault();
|
||
const orientations = ['normal', 'vertical', 'upsidedown', 'vertical-reverse', 'landscape'];
|
||
const currentIndex = orientations.indexOf(this.config.orientation);
|
||
const nextIndex = (currentIndex + 1) % orientations.length;
|
||
this.config.orientation = orientations[nextIndex];
|
||
this.applyConfig();
|
||
this.saveConfig();
|
||
}
|
||
|
||
// Ctrl/Cmd + Shift + D: Toggle dark mode
|
||
if (event.key === 'D' && (event.ctrlKey || event.metaKey) && event.shiftKey) {
|
||
event.preventDefault();
|
||
const modes = ['auto', 'on', 'off'];
|
||
const currentIndex = modes.indexOf(this.config.darkMode);
|
||
const nextIndex = (currentIndex + 1) % modes.length;
|
||
this.config.darkMode = modes[nextIndex];
|
||
this.applyConfig();
|
||
this.saveConfig();
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Ensure content wrapper exists for rotated orientations
|
||
*/
|
||
ensureContentWrapper() {
|
||
if (!document.getElementById('content-wrapper')) {
|
||
console.log('Creating content wrapper');
|
||
const wrapper = document.createElement('div');
|
||
wrapper.id = 'content-wrapper';
|
||
|
||
// Move all body children to the wrapper except config elements
|
||
const configElements = ['config-button', 'config-modal', 'background-overlay'];
|
||
|
||
// Create an array of nodes to move (can't modify while iterating)
|
||
const nodesToMove = [];
|
||
for (let i = 0; i < document.body.children.length; i++) {
|
||
const child = document.body.children[i];
|
||
if (!configElements.includes(child.id) && child.id !== 'content-wrapper') {
|
||
nodesToMove.push(child);
|
||
}
|
||
}
|
||
|
||
// Move the nodes to the wrapper
|
||
nodesToMove.forEach(node => {
|
||
wrapper.appendChild(node);
|
||
});
|
||
|
||
// Add the wrapper back to the body
|
||
document.body.appendChild(wrapper);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Export the ConfigManager class for use in other modules
|
||
window.ConfigManager = ConfigManager;
|