feat(ui): enhance calendar merging interface with search functionality
This commit is contained in:
parent
f7b72efd06
commit
258bacb7b0
5 changed files with 350 additions and 73 deletions
|
@ -9,9 +9,11 @@
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>📅 Calendar Merger</h1>
|
<h1>📅 Calendar Merger</h1>
|
||||||
|
|
||||||
|
<!-- Merge Form -->
|
||||||
<div class="form-card">
|
<div class="form-card">
|
||||||
<form id="merge-form">
|
<form id="merge-form">
|
||||||
|
<h2>Merge Calendars</h2>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input type="text"
|
<input type="text"
|
||||||
id="link-group-name"
|
id="link-group-name"
|
||||||
|
@ -42,7 +44,25 @@
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="result"></div>
|
<!-- Result Section -->
|
||||||
|
<div id="result-container">
|
||||||
|
<p class="result-info">After merging, your calendar URL will appear here.</p>
|
||||||
|
<div id="result"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Search Section -->
|
||||||
|
<div class="form-card search-section">
|
||||||
|
<h2>Find Existing Calendar</h2>
|
||||||
|
<div class="input-group">
|
||||||
|
<div class="search-container">
|
||||||
|
<input type="text" id="calendar-search" placeholder="Enter calendar name">
|
||||||
|
<button type="button" id="search-btn" class="button primary-btn">Search</button>
|
||||||
|
</div>
|
||||||
|
<div id="search-result"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="script.js"></script>
|
<script src="script.js"></script>
|
||||||
|
|
290
public/script.js
290
public/script.js
|
@ -2,7 +2,18 @@ const form = document.getElementById('merge-form');
|
||||||
const calendars = document.getElementById('calendars');
|
const calendars = document.getElementById('calendars');
|
||||||
const addCalendarButton = document.getElementById('add-calendar');
|
const addCalendarButton = document.getElementById('add-calendar');
|
||||||
const result = document.getElementById('result');
|
const result = document.getElementById('result');
|
||||||
|
const resultInfo = document.querySelector('.result-info');
|
||||||
|
|
||||||
|
// Search functionality elements
|
||||||
|
const searchBtn = document.getElementById('search-btn');
|
||||||
|
const calendarSearch = document.getElementById('calendar-search');
|
||||||
|
const searchResult = document.getElementById('search-result');
|
||||||
|
|
||||||
|
// Variable to track if we're editing an existing calendar
|
||||||
|
let isEditing = false;
|
||||||
|
let currentCalendarName = '';
|
||||||
|
|
||||||
|
// Variable to track the index of the calendar being added
|
||||||
let calendarIndex = 1;
|
let calendarIndex = 1;
|
||||||
let mergedUrl = '';
|
let mergedUrl = '';
|
||||||
|
|
||||||
|
@ -12,78 +23,221 @@ function isValidUrl(url) {
|
||||||
return urlPattern.test(url);
|
return urlPattern.test(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
addCalendarButton.addEventListener('click', () => {
|
// Function to extract calendar name from URL or input
|
||||||
const newCalendar = document.createElement('div');
|
function extractCalendarName(input) {
|
||||||
newCalendar.className = 'calendar-entry';
|
// If it's a URL, extract the last part of the path
|
||||||
newCalendar.innerHTML = `
|
if (input.startsWith('http')) {
|
||||||
<input type="url" id="url-${calendarIndex}" placeholder="https://example.com/calendar.ics">
|
try {
|
||||||
<input type="text" id="prefix-${calendarIndex}" placeholder="Event prefix">
|
// Remove trailing slash if present
|
||||||
<div class="checkbox-group">
|
if (input.endsWith('/')) {
|
||||||
<input type="checkbox" id="override-${calendarIndex}">
|
input = input.slice(0, -1);
|
||||||
<label for="override-${calendarIndex}">Override</label>
|
}
|
||||||
</div>
|
|
||||||
<button type="button" class="remove-btn" title="Remove calendar"></button>
|
|
||||||
`;
|
|
||||||
calendars.appendChild(newCalendar);
|
|
||||||
calendarIndex++;
|
|
||||||
});
|
|
||||||
|
|
||||||
form.addEventListener('submit', (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const linkGroupName = document.getElementById('link-group-name').value;
|
|
||||||
const calendarsData = [];
|
|
||||||
let valid = true; // Flag to track URL validity
|
|
||||||
|
|
||||||
for (let i = 0; i < calendarIndex; i++) {
|
// Extract the last part of the path
|
||||||
const prefix = document.getElementById(`prefix-${i}`);
|
const url = new URL(input);
|
||||||
const override = document.getElementById(`override-${i}`);
|
const pathParts = url.pathname.split('/').filter(part => part.length > 0);
|
||||||
const url = document.getElementById(`url-${i}`);
|
|
||||||
|
// If there's a path part, use the last one
|
||||||
if (prefix && override && url) {
|
if (pathParts.length > 0) {
|
||||||
// Validate the URL
|
let lastPart = pathParts[pathParts.length - 1];
|
||||||
if (!isValidUrl(url.value)) {
|
|
||||||
valid = false; // Set flag to false if any URL is invalid
|
// Remove .ics extension if present
|
||||||
alert(`Invalid URL format for calendar ${i + 1}: ${url.value}`);
|
if (lastPart.endsWith('.ics')) {
|
||||||
} else {
|
lastPart = lastPart.slice(0, -4);
|
||||||
calendarsData.push({
|
|
||||||
prefix: prefix.value,
|
|
||||||
override: override.checked,
|
|
||||||
url: url.value
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return lastPart;
|
||||||
}
|
}
|
||||||
if (valid) {
|
} catch (e) {
|
||||||
fetch('/merge', {
|
console.error('Error parsing URL:', e);
|
||||||
method: 'POST',
|
}
|
||||||
headers: {
|
}
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
// If not a URL or URL parsing failed, just return the input as is
|
||||||
body: JSON.stringify({ linkGroupName, calendars: calendarsData })
|
return input;
|
||||||
})
|
}
|
||||||
.then((response) => {
|
|
||||||
if (!response.ok) {
|
// Event listener for the search button
|
||||||
return response.json().then(err => { throw err; });
|
if (searchBtn) {
|
||||||
|
searchBtn.addEventListener('click', searchCalendar);
|
||||||
|
|
||||||
|
// Also search when pressing Enter in the search field
|
||||||
|
if (calendarSearch) {
|
||||||
|
calendarSearch.addEventListener('keypress', function(e) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
searchCalendar();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to search for a calendar
|
||||||
|
function searchCalendar() {
|
||||||
|
let calendarName = calendarSearch.value.trim();
|
||||||
|
if (!calendarName) {
|
||||||
|
searchResult.innerHTML = '<div class="alert alert-warning">Please enter a calendar name</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract just the calendar name if a URL was entered
|
||||||
|
calendarName = extractCalendarName(calendarName);
|
||||||
|
|
||||||
|
searchResult.innerHTML = '<div class="spinner-border text-primary" role="status"><span class="visually-hidden">Loading...</span></div>';
|
||||||
|
|
||||||
|
// Check if calendar exists
|
||||||
|
fetch(`/calendar-config/${calendarName}`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.exists) {
|
||||||
|
searchResult.innerHTML = `<div class="alert alert-success">Calendar found!</div>`;
|
||||||
|
loadCalendarConfig(data.config, calendarName);
|
||||||
|
} else {
|
||||||
|
searchResult.innerHTML = `<div class="alert alert-danger">Calendar not found</div>`;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error searching for calendar:', error);
|
||||||
|
searchResult.innerHTML = `<div class="alert alert-danger">Error searching for calendar</div>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to load calendar configuration for editing
|
||||||
|
function loadCalendarConfig(config, calendarName) {
|
||||||
|
// Set editing mode
|
||||||
|
isEditing = true;
|
||||||
|
currentCalendarName = calendarName;
|
||||||
|
|
||||||
|
// Update link group name
|
||||||
|
document.getElementById('link-group-name').value = config.linkGroupName;
|
||||||
|
|
||||||
|
// Clear existing calendars
|
||||||
|
while (calendars.children.length > 0) {
|
||||||
|
calendars.removeChild(calendars.lastChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset calendar index
|
||||||
|
calendarIndex = 0;
|
||||||
|
|
||||||
|
// Add calendars from config
|
||||||
|
config.calendars.forEach(calendar => {
|
||||||
|
const newCalendar = document.createElement('div');
|
||||||
|
newCalendar.className = 'calendar-entry';
|
||||||
|
newCalendar.innerHTML = `
|
||||||
|
<input type="url" id="url-${calendarIndex}" placeholder="https://example.com/calendar.ics" value="${calendar.url}">
|
||||||
|
<input type="text" id="prefix-${calendarIndex}" placeholder="Event prefix" value="${calendar.prefix || ''}">
|
||||||
|
<div class="checkbox-group">
|
||||||
|
<input type="checkbox" id="override-${calendarIndex}" ${calendar.override ? 'checked' : ''}>
|
||||||
|
<label for="override-${calendarIndex}">Override</label>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="remove-btn" title="Remove calendar"></button>
|
||||||
|
`;
|
||||||
|
calendars.appendChild(newCalendar);
|
||||||
|
calendarIndex++;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scroll to the form
|
||||||
|
form.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event listener for adding new calendar
|
||||||
|
addCalendarButton.addEventListener('click', () => {
|
||||||
|
const newCalendar = document.createElement('div');
|
||||||
|
newCalendar.className = 'calendar-entry';
|
||||||
|
newCalendar.innerHTML = `
|
||||||
|
<input type="url" id="url-${calendarIndex}" placeholder="https://example.com/calendar.ics">
|
||||||
|
<input type="text" id="prefix-${calendarIndex}" placeholder="Event prefix">
|
||||||
|
<div class="checkbox-group">
|
||||||
|
<input type="checkbox" id="override-${calendarIndex}">
|
||||||
|
<label for="override-${calendarIndex}">Override</label>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="remove-btn" title="Remove calendar"></button>
|
||||||
|
`;
|
||||||
|
calendars.appendChild(newCalendar);
|
||||||
|
calendarIndex++;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Event listener for form submission
|
||||||
|
form.addEventListener('submit', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const linkGroupName = document.getElementById('link-group-name').value;
|
||||||
|
const calendarsData = [];
|
||||||
|
let valid = true; // Flag to track URL validity
|
||||||
|
|
||||||
|
for (let i = 0; i < calendarIndex; i++) {
|
||||||
|
const prefix = document.getElementById(`prefix-${i}`);
|
||||||
|
const override = document.getElementById(`override-${i}`);
|
||||||
|
const url = document.getElementById(`url-${i}`);
|
||||||
|
if (prefix && override && url && url.value) {
|
||||||
|
// Validate the URL
|
||||||
|
if (url.value.startsWith('http') && !isValidUrl(url.value)) {
|
||||||
|
valid = false; // Set flag to false if any URL is invalid
|
||||||
|
alert(`Invalid URL format for calendar ${i + 1}: ${url.value}`);
|
||||||
|
} else {
|
||||||
|
calendarsData.push({
|
||||||
|
prefix: prefix.value,
|
||||||
|
override: override.checked,
|
||||||
|
url: url.value
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (valid) {
|
||||||
|
// Determine if we're creating or updating
|
||||||
|
const endpoint = isEditing ? `/calendar/${currentCalendarName}` : '/merge';
|
||||||
|
const method = isEditing ? 'PUT' : 'POST';
|
||||||
|
|
||||||
|
fetch(endpoint, {
|
||||||
|
method: method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ linkGroupName, calendars: calendarsData })
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
if (!response.ok) {
|
||||||
|
return response.json().then(err => { throw err; });
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
mergedUrl = data.url;
|
||||||
|
|
||||||
|
// Hide the info text when showing results
|
||||||
|
if (resultInfo) {
|
||||||
|
resultInfo.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
result.innerHTML = `Updated calendar URL: <a href="${data.url}">${data.url}</a>`;
|
||||||
|
|
||||||
|
// Update the search result
|
||||||
|
if (searchResult) {
|
||||||
|
searchResult.innerHTML = `<div class="alert alert-success">Calendar updated successfully!</div>`;
|
||||||
}
|
}
|
||||||
return response.json();
|
|
||||||
})
|
// Reset editing state
|
||||||
.then((data) => {
|
isEditing = false;
|
||||||
mergedUrl = data.url;
|
currentCalendarName = '';
|
||||||
|
} else {
|
||||||
result.innerHTML = `Merged calendar URL: <a href="${data.url}">${data.url}</a>`;
|
result.innerHTML = `Merged calendar URL: <a href="${data.url}">${data.url}</a>`;
|
||||||
console.log('Links added successfully!');
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Error:', error);
|
|
||||||
result.innerHTML = `Error merging calendars: ${error.message || 'Unknown error'}`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('click', (event) => {
|
|
||||||
if (event.target.classList.contains('remove-btn')) {
|
|
||||||
const calendarEntry = event.target.closest('.calendar-entry');
|
|
||||||
if (calendarEntry) {
|
|
||||||
calendarEntry.remove();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('Operation completed successfully!');
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
result.innerHTML = `Error: ${error.message || 'Unknown error'}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Event listener for removing a calendar entry
|
||||||
|
document.addEventListener('click', (event) => {
|
||||||
|
if (event.target.classList.contains('remove-btn')) {
|
||||||
|
const calendarEntry = event.target.closest('.calendar-entry');
|
||||||
|
if (calendarEntry) {
|
||||||
|
calendarEntry.remove();
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
});
|
|
@ -112,6 +112,7 @@
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
border: 2px dashed var(--border);
|
border: 2px dashed var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -156,4 +157,31 @@
|
||||||
content: '×';
|
content: '×';
|
||||||
font-size: 1.4rem;
|
font-size: 1.4rem;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Search Section Styling */
|
||||||
|
.search-section {
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 2rem;
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-container {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-container input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-container button {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
#search-result {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
|
@ -39,6 +39,67 @@ router.post('/merge', async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// New endpoint to check if a calendar exists and return its configuration
|
||||||
|
router.get('/calendar-config/:name', (req, res) => {
|
||||||
|
const calendarName = sanitizeFilename(req.params.name);
|
||||||
|
const jsonFilePath = path.join(MERGED_CALENDARS_DIR, `${calendarName}.json`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(jsonFilePath)) {
|
||||||
|
const configData = JSON.parse(fs.readFileSync(jsonFilePath, 'utf-8'));
|
||||||
|
res.json({
|
||||||
|
exists: true,
|
||||||
|
config: configData,
|
||||||
|
url: `${req.protocol}://${req.get('host')}/calendar/${calendarName}`
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.json({ exists: false });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking calendar:', error.message);
|
||||||
|
res.status(500).json({ error: 'Failed to check calendar configuration' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update an existing calendar configuration
|
||||||
|
router.put('/calendar/:name', async (req, res) => {
|
||||||
|
const calendarName = sanitizeFilename(req.params.name);
|
||||||
|
const { linkGroupName, calendars } = req.body;
|
||||||
|
|
||||||
|
// Validate the input
|
||||||
|
if (!linkGroupName || !Array.isArray(calendars) || calendars.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'Invalid input. Please provide a linkGroupName and at least one calendar.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if the calendar exists
|
||||||
|
const jsonFilePath = path.join(MERGED_CALENDARS_DIR, `${calendarName}.json`);
|
||||||
|
if (!fs.existsSync(jsonFilePath)) {
|
||||||
|
return res.status(404).json({ error: 'Calendar not found.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch calendar data
|
||||||
|
const results = await Promise.all(calendars.map(fetchCalendarData));
|
||||||
|
|
||||||
|
// Generate merged calendar using ical.js
|
||||||
|
const calendarComponent = createCalendarComponent(linkGroupName);
|
||||||
|
addEventsToCalendar(calendarComponent, results);
|
||||||
|
|
||||||
|
// Save the updated calendar to a file
|
||||||
|
saveCalendarFile(`${calendarName}.ics`, calendarComponent.toString());
|
||||||
|
|
||||||
|
// Save the updated configuration
|
||||||
|
saveCalendarFile(`${calendarName}.json`, JSON.stringify({ linkGroupName, calendars }, null, 2));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
url: `${req.protocol}://${req.get('host')}/calendar/${calendarName}`
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating calendar:', error.message);
|
||||||
|
res.status(500).json({ error: 'Failed to update calendar' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Refresh calendar if outdated
|
// Refresh calendar if outdated
|
||||||
async function refreshCalendarData(calendarName) {
|
async function refreshCalendarData(calendarName) {
|
||||||
const jsonFilePath = path.join(MERGED_CALENDARS_DIR, `${calendarName}.json`);
|
const jsonFilePath = path.join(MERGED_CALENDARS_DIR, `${calendarName}.json`);
|
||||||
|
|
|
@ -7,8 +7,22 @@ console.log(`Starting server in ${process.cwd()}`);
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
// Serve static files from the 'public' directory
|
// Mount API routes BEFORE static files
|
||||||
app.use(express.static(path.join(process.cwd(), 'public')));
|
// This ensures that API requests are handled by your routes first
|
||||||
app.use('/', routes);
|
app.use('/', routes);
|
||||||
|
|
||||||
|
// Serve static files from the 'public' directory
|
||||||
|
// This will only handle requests that weren't matched by the routes
|
||||||
|
app.use(express.static(path.join(process.cwd(), 'public')));
|
||||||
|
|
||||||
|
// Optional: Add a catch-all route for client-side routing
|
||||||
|
app.get('*', (req, res) => {
|
||||||
|
// Only serve index.html for non-API, non-file requests
|
||||||
|
if (!req.path.startsWith('/api') && !req.path.includes('.')) {
|
||||||
|
res.sendFile(path.join(process.cwd(), 'public', 'index.html'));
|
||||||
|
} else {
|
||||||
|
res.status(404).send('Not found');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default app;
|
export default app;
|
Loading…
Add table
Reference in a new issue