Optimize landscape layout: 4-column grid, transport icons, improved sizing and spacing
This commit is contained in:
725
config.js
725
config.js
@@ -35,7 +35,6 @@ class ConfigManager {
|
||||
darkMode: this.options.defaultDarkMode,
|
||||
backgroundImage: '',
|
||||
backgroundOpacity: 0.3, // Default opacity (30%)
|
||||
tickerSpeed: 60, // Default ticker speed in seconds
|
||||
sites: [
|
||||
{
|
||||
id: '1411',
|
||||
@@ -43,13 +42,6 @@ class ConfigManager {
|
||||
enabled: true
|
||||
}
|
||||
],
|
||||
rssFeeds: [
|
||||
{
|
||||
name: "Travel Alerts",
|
||||
url: "https://travel.state.gov/content/travel/en/rss/rss.xml",
|
||||
enabled: true
|
||||
}
|
||||
],
|
||||
combineSameDirection: true, // Combine departures going in the same direction
|
||||
...this.loadConfig() // Load saved config if available
|
||||
};
|
||||
@@ -102,73 +94,89 @@ class ConfigManager {
|
||||
<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">
|
||||
<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>
|
||||
<!-- 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>
|
||||
<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
|
||||
|
||||
<!-- 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>
|
||||
<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 class="config-option">
|
||||
<label for="ticker-speed">Ticker Speed: <span id="ticker-speed-value">${this.config.tickerSpeed}</span> seconds</label>
|
||||
<input type="range" id="ticker-speed" min="10" max="120" step="5" value="${this.config.tickerSpeed}">
|
||||
</div>
|
||||
<div class="config-option">
|
||||
<label>Transit Sites:</label>
|
||||
<div id="sites-container">
|
||||
${this.generateSitesHTML()}
|
||||
</div>
|
||||
<div style="margin-top: 10px;">
|
||||
<button id="add-site-button" style="padding: 5px 10px;">Add Site</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-option">
|
||||
<label>RSS Feeds:</label>
|
||||
<div id="rss-feeds-container">
|
||||
${this.generateRssFeedsHTML()}
|
||||
</div>
|
||||
<div style="margin-top: 10px;">
|
||||
<button id="add-feed-button" style="padding: 5px 10px;">Add Feed</button>
|
||||
</div>
|
||||
</div>
|
||||
<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 class="config-modal-footer">
|
||||
@@ -180,6 +188,9 @@ class ConfigManager {
|
||||
|
||||
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());
|
||||
@@ -196,10 +207,22 @@ class ConfigManager {
|
||||
addSiteButton.addEventListener('click', () => this.addNewSite());
|
||||
}
|
||||
|
||||
// Add event listener for add feed button
|
||||
const addFeedButton = modalContainer.querySelector('#add-feed-button');
|
||||
if (addFeedButton) {
|
||||
addFeedButton.addEventListener('click', () => this.addNewFeed());
|
||||
// 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
|
||||
@@ -226,9 +249,8 @@ class ConfigManager {
|
||||
}
|
||||
});
|
||||
|
||||
// Add event listeners for site and feed management
|
||||
// Add event listeners for site management
|
||||
this.setupSiteEventListeners();
|
||||
this.setupFeedEventListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -250,25 +272,6 @@ class ConfigManager {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup event listeners for RSS feed management
|
||||
*/
|
||||
setupFeedEventListeners() {
|
||||
const feedsContainer = document.getElementById('rss-feeds-container');
|
||||
if (!feedsContainer) return;
|
||||
|
||||
const removeButtons = feedsContainer.querySelectorAll('.remove-feed-button');
|
||||
removeButtons.forEach(button => {
|
||||
button.addEventListener('click', (event) => {
|
||||
const feedItem = event.target.closest('.feed-item');
|
||||
if (feedItem) {
|
||||
const index = parseInt(feedItem.dataset.index);
|
||||
this.removeFeed(index);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new site to the configuration
|
||||
*/
|
||||
@@ -286,23 +289,6 @@ class ConfigManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new RSS feed to the configuration
|
||||
*/
|
||||
addNewFeed() {
|
||||
this.config.rssFeeds.push({
|
||||
name: 'New Feed',
|
||||
url: '',
|
||||
enabled: true
|
||||
});
|
||||
|
||||
const feedsContainer = document.getElementById('rss-feeds-container');
|
||||
if (feedsContainer) {
|
||||
feedsContainer.innerHTML = this.generateRssFeedsHTML();
|
||||
this.setupFeedEventListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a site from the configuration
|
||||
*/
|
||||
@@ -319,18 +305,28 @@ class ConfigManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an RSS feed from the configuration
|
||||
* Setup tab switching functionality
|
||||
*/
|
||||
removeFeed(index) {
|
||||
if (index >= 0 && index < this.config.rssFeeds.length) {
|
||||
this.config.rssFeeds.splice(index, 1);
|
||||
|
||||
const feedsContainer = document.getElementById('rss-feeds-container');
|
||||
if (feedsContainer) {
|
||||
feedsContainer.innerHTML = this.generateRssFeedsHTML();
|
||||
this.setupFeedEventListeners();
|
||||
}
|
||||
}
|
||||
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');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -360,6 +356,18 @@ class ConfigManager {
|
||||
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;
|
||||
@@ -372,19 +380,10 @@ class ConfigManager {
|
||||
this.setupSiteEventListeners();
|
||||
}
|
||||
|
||||
// Update RSS feeds container
|
||||
const feedsContainer = document.getElementById('rss-feeds-container');
|
||||
if (feedsContainer) {
|
||||
feedsContainer.innerHTML = this.generateRssFeedsHTML();
|
||||
this.setupFeedEventListeners();
|
||||
}
|
||||
|
||||
// Update background image, opacity, and ticker speed values
|
||||
// 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');
|
||||
const tickerSpeed = document.getElementById('ticker-speed');
|
||||
const tickerSpeedValue = document.getElementById('ticker-speed-value');
|
||||
|
||||
if (backgroundImageUrl) {
|
||||
backgroundImageUrl.value = this.config.backgroundImage || '';
|
||||
@@ -398,14 +397,6 @@ class ConfigManager {
|
||||
opacityValue.textContent = `${Math.round(this.config.backgroundOpacity * 100)}%`;
|
||||
}
|
||||
|
||||
if (tickerSpeed) {
|
||||
tickerSpeed.value = this.config.tickerSpeed;
|
||||
}
|
||||
|
||||
if (tickerSpeedValue) {
|
||||
tickerSpeedValue.textContent = this.config.tickerSpeed;
|
||||
}
|
||||
|
||||
// Update background preview
|
||||
this.updateBackgroundPreview();
|
||||
|
||||
@@ -438,16 +429,6 @@ class ConfigManager {
|
||||
});
|
||||
}
|
||||
|
||||
// Ticker speed slider
|
||||
const tickerSpeedSlider = document.getElementById('ticker-speed');
|
||||
if (tickerSpeedSlider) {
|
||||
const newSlider = tickerSpeedSlider.cloneNode(true);
|
||||
tickerSpeedSlider.parentNode.replaceChild(newSlider, tickerSpeedSlider);
|
||||
newSlider.addEventListener('input', (e) => {
|
||||
document.getElementById('ticker-speed-value').textContent = e.target.value;
|
||||
});
|
||||
}
|
||||
|
||||
// Local image selection
|
||||
const localImageInput = document.getElementById('local-image-input');
|
||||
if (localImageInput) {
|
||||
@@ -477,12 +458,10 @@ class ConfigManager {
|
||||
const darkModeSelect = document.getElementById('dark-mode-select');
|
||||
const backgroundImageUrl = document.getElementById('background-image-url');
|
||||
const backgroundOpacity = document.getElementById('background-opacity');
|
||||
const tickerSpeed = document.getElementById('ticker-speed');
|
||||
const combineDirections = document.getElementById('combine-directions');
|
||||
|
||||
// Get sites and feeds configuration from form
|
||||
// Get sites configuration from form
|
||||
const sitesConfig = this.getSitesFromForm();
|
||||
const feedsConfig = this.getRssFeedsFromForm();
|
||||
|
||||
// Get the background image URL directly from the DOM
|
||||
const imageUrlValue = document.querySelector('#background-image-url').value;
|
||||
@@ -492,10 +471,8 @@ class ConfigManager {
|
||||
this.config.darkMode = darkModeSelect.value;
|
||||
this.config.backgroundImage = imageUrlValue;
|
||||
this.config.backgroundOpacity = parseFloat(backgroundOpacity.value);
|
||||
this.config.tickerSpeed = parseInt(tickerSpeed.value);
|
||||
this.config.combineSameDirection = combineDirections.checked;
|
||||
this.config.sites = sitesConfig;
|
||||
this.config.rssFeeds = feedsConfig;
|
||||
|
||||
// Save config
|
||||
this.saveConfig();
|
||||
@@ -503,11 +480,6 @@ class ConfigManager {
|
||||
// Sync with server
|
||||
this.syncConfig();
|
||||
|
||||
// Update ticker speed if changed
|
||||
if (window.tickerManager && this.config.tickerSpeed) {
|
||||
window.tickerManager.setScrollSpeed(this.config.tickerSpeed);
|
||||
}
|
||||
|
||||
// Apply config
|
||||
this.applyConfig();
|
||||
|
||||
@@ -532,9 +504,6 @@ class ConfigManager {
|
||||
// Apply background image and opacity
|
||||
this.applyBackgroundImage();
|
||||
|
||||
// Apply ticker speed
|
||||
this.applyTickerSpeed();
|
||||
|
||||
// Ensure content wrapper exists when changing to rotated modes
|
||||
if (['vertical', 'upsidedown', 'vertical-reverse'].includes(this.config.orientation)) {
|
||||
this.ensureContentWrapper();
|
||||
@@ -612,15 +581,6 @@ class ConfigManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply ticker speed setting
|
||||
*/
|
||||
applyTickerSpeed() {
|
||||
if (window.tickerManager) {
|
||||
window.tickerManager.setScrollSpeed(this.config.tickerSpeed);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the sun times display in the config modal
|
||||
*/
|
||||
@@ -668,26 +628,401 @@ class ConfigManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate HTML for RSS feeds configuration
|
||||
* Search for transit sites
|
||||
*/
|
||||
generateRssFeedsHTML() {
|
||||
if (!this.config.rssFeeds || this.config.rssFeeds.length === 0) {
|
||||
return '<div class="no-feeds">No RSS feeds configured</div>';
|
||||
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;
|
||||
}
|
||||
|
||||
return this.config.rssFeeds.map((feed, index) => `
|
||||
<div class="feed-item" data-index="${index}">
|
||||
<div style="display: flex; align-items: center; margin-bottom: 5px;">
|
||||
<input type="checkbox" class="feed-enabled" ${feed.enabled ? 'checked' : ''}>
|
||||
<input type="text" class="feed-name" value="${feed.name}" placeholder="Feed Name" style="flex: 1; margin: 0 5px;">
|
||||
<button class="remove-feed-button" style="padding: 2px 5px;">×</button>
|
||||
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>
|
||||
<div style="display: flex; align-items: center;">
|
||||
<span style="margin-right: 5px;">URL:</span>
|
||||
<input type="text" class="feed-url" value="${feed.url}" placeholder="Feed URL" style="flex: 1;">
|
||||
`).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>
|
||||
`).join('');
|
||||
`;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -706,22 +1041,6 @@ class ConfigManager {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get RSS feeds configuration from form
|
||||
*/
|
||||
getRssFeedsFromForm() {
|
||||
const feedsContainer = document.getElementById('rss-feeds-container');
|
||||
const feedItems = feedsContainer.querySelectorAll('.feed-item');
|
||||
|
||||
return Array.from(feedItems).map(item => {
|
||||
return {
|
||||
name: item.querySelector('.feed-name').value,
|
||||
url: item.querySelector('.feed-url').value,
|
||||
enabled: item.querySelector('.feed-enabled').checked
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync configuration with server
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user