Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request 'feat(calendar): drag-drop ICS import, rescheduling, duplication' (#342) from feat/calendar-drag-drop into main

scott 11740b4e ee883743

+216 -12
+9
src/calendar/index.html
··· 90 90 <!-- Calendar grid --> 91 91 <div class="calendar-grid" id="calendar-grid"></div> 92 92 </div> 93 + 94 + <!-- Drop overlay for .ics file drag-and-drop --> 95 + <div id="cal-drop-overlay" class="cal-drop-overlay" style="display:none"> 96 + <div class="cal-drop-overlay-inner"> 97 + <span class="cal-drop-icon">&#128197;</span> 98 + <span>Drop .ics file to import</span> 99 + </div> 100 + </div> 93 101 </main> 94 102 95 103 <!-- Event modal --> ··· 147 155 148 156 <div class="event-modal-actions"> 149 157 <button class="btn-danger" id="btn-event-delete" title="Delete event" style="display:none">Delete</button> 158 + <button class="btn-secondary" id="btn-event-duplicate" title="Duplicate event" style="display:none">Duplicate</button> 150 159 <span class="topbar-spacer"></span> 151 160 <button class="btn-secondary" id="btn-event-cancel">Cancel</button> 152 161 <button class="btn-primary" id="btn-event-save">Save</button>
+161 -12
src/calendar/main.ts
··· 125 125 const modalDescription = document.getElementById('event-description') as HTMLTextAreaElement; 126 126 const modalSave = document.getElementById('btn-event-save') as HTMLButtonElement; 127 127 const modalDelete = document.getElementById('btn-event-delete') as HTMLButtonElement; 128 + const modalDuplicate = document.getElementById('btn-event-duplicate') as HTMLButtonElement; 128 129 const modalCancel = document.getElementById('btn-event-cancel') as HTMLButtonElement; 129 130 const modalTitleEl = document.getElementById('event-modal-title') as HTMLElement; 130 131 ··· 417 418 const timeStr = evt.allDay || pos !== 'single' ? '' : formatTimeDisplay(evt.startTime); 418 419 const timeLabel = timeStr ? `<span class="cal-pill-time">${escapeHtml(timeStr)}</span> ` : ''; 419 420 const showTitle = pos === 'start' || pos === 'single'; 420 - html += `<div class="cal-event-pill${multiClass}" data-event-id="${escapeHtml(evt.id)}" style="--pill-color: ${evt.color}">`; 421 + html += `<div class="cal-event-pill${multiClass}" data-event-id="${escapeHtml(evt.id)}" draggable="true" style="--pill-color: ${evt.color}">`; 421 422 html += showTitle ? `${timeLabel}${escapeHtml(evt.title || 'Untitled')}` : '&nbsp;'; 422 423 html += '</div>'; 423 424 } ··· 750 751 // Toggle time fields visibility based on all-day 751 752 updateTimeFieldsVisibility(); 752 753 753 - // Show/hide delete button 754 + // Show/hide delete + duplicate buttons 754 755 modalDelete.style.display = existing ? '' : 'none'; 756 + modalDuplicate.style.display = existing ? '' : 'none'; 755 757 756 758 // Update modal title 757 759 modalTitleEl.textContent = existing ? 'Edit Event' : 'New Event'; ··· 806 808 closeModal(); 807 809 } 808 810 811 + function duplicateEvent(): void { 812 + if (!editingEventId) return; 813 + const original = state.events.find(e => e.id === editingEventId); 814 + if (!original) return; 815 + const now = Date.now(); 816 + const dup: CalendarEvent = { 817 + ...original, 818 + id: crypto.randomUUID(), 819 + title: `${original.title} (copy)`, 820 + createdAt: now, 821 + updatedAt: now, 822 + }; 823 + addEventToYjs(dup); 824 + closeModal(); 825 + showToast(`Duplicated "${original.title}"`, 2000); 826 + } 827 + 809 828 // Modal event wiring 810 829 modalSave.addEventListener('click', saveEvent); 811 830 modalDelete.addEventListener('click', deleteEvent); 831 + modalDuplicate.addEventListener('click', duplicateEvent); 812 832 modalCancel.addEventListener('click', closeModal); 813 833 modalAllDay.addEventListener('change', updateTimeFieldsVisibility); 814 834 modalBackdrop.addEventListener('click', (e) => { ··· 1082 1102 }, { passive: true }); 1083 1103 1084 1104 // --------------------------------------------------------------------------- 1085 - // iCal import 1105 + // iCal import (shared logic for button and drag-and-drop) 1086 1106 // --------------------------------------------------------------------------- 1087 1107 1088 - const importBtn = document.getElementById('btn-import'); 1089 - const importInput = document.getElementById('ics-import-input') as HTMLInputElement | null; 1090 - 1091 - importBtn?.addEventListener('click', () => importInput?.click()); 1092 - 1093 - importInput?.addEventListener('change', async () => { 1094 - const file = importInput.files?.[0]; 1095 - if (!file) return; 1108 + async function importIcsFromFile(file: File): Promise<void> { 1109 + const ext = file.name.split('.').pop()?.toLowerCase(); 1110 + if (!['ics', 'ical', 'ifb', 'icalendar'].includes(ext || '')) { 1111 + showToast(`Unsupported file type: .${ext}`, 4000); 1112 + return; 1113 + } 1096 1114 1097 1115 try { 1098 1116 const text = await file.text(); ··· 1122 1140 } catch { 1123 1141 showToast('Failed to read calendar file', 5000); 1124 1142 } 1143 + } 1144 + 1145 + const importBtn = document.getElementById('btn-import'); 1146 + const importInput = document.getElementById('ics-import-input') as HTMLInputElement | null; 1125 1147 1126 - // Reset input so the same file can be re-imported 1148 + importBtn?.addEventListener('click', () => importInput?.click()); 1149 + 1150 + importInput?.addEventListener('change', async () => { 1151 + const file = importInput.files?.[0]; 1152 + if (!file) return; 1153 + await importIcsFromFile(file); 1127 1154 importInput.value = ''; 1155 + }); 1156 + 1157 + // --------------------------------------------------------------------------- 1158 + // Drag-and-drop .ics import 1159 + // --------------------------------------------------------------------------- 1160 + 1161 + const dropOverlay = document.getElementById('cal-drop-overlay'); 1162 + let dragCounter = 0; 1163 + 1164 + document.addEventListener('dragenter', (e) => { 1165 + // Only show overlay for file drags, not internal event drags 1166 + if (e.dataTransfer?.types.includes('Files')) { 1167 + e.preventDefault(); 1168 + dragCounter++; 1169 + if (dragCounter === 1 && dropOverlay) dropOverlay.style.display = ''; 1170 + } 1171 + }); 1172 + 1173 + document.addEventListener('dragover', (e) => { 1174 + if (e.dataTransfer?.types.includes('Files')) { 1175 + e.preventDefault(); 1176 + if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; 1177 + } 1178 + }); 1179 + 1180 + document.addEventListener('dragleave', (e) => { 1181 + if (e.dataTransfer?.types.includes('Files')) { 1182 + e.preventDefault(); 1183 + dragCounter--; 1184 + if (dragCounter <= 0) { 1185 + dragCounter = 0; 1186 + if (dropOverlay) dropOverlay.style.display = 'none'; 1187 + } 1188 + } 1189 + }); 1190 + 1191 + document.addEventListener('drop', async (e) => { 1192 + // Only handle file drops here, not internal event drags 1193 + if (!e.dataTransfer?.types.includes('Files')) return; 1194 + e.preventDefault(); 1195 + dragCounter = 0; 1196 + if (dropOverlay) dropOverlay.style.display = 'none'; 1197 + 1198 + const files = e.dataTransfer?.files; 1199 + if (!files || files.length === 0) return; 1200 + 1201 + // Import all .ics files dropped 1202 + let imported = 0; 1203 + for (const file of Array.from(files)) { 1204 + const ext = file.name.split('.').pop()?.toLowerCase(); 1205 + if (['ics', 'ical', 'ifb', 'icalendar'].includes(ext || '')) { 1206 + await importIcsFromFile(file); 1207 + imported++; 1208 + } 1209 + } 1210 + if (imported === 0) { 1211 + showToast('No .ics files found. Drop a calendar file to import.', 4000); 1212 + } 1213 + }); 1214 + 1215 + // --------------------------------------------------------------------------- 1216 + // Drag-and-drop event rescheduling (month view) 1217 + // --------------------------------------------------------------------------- 1218 + 1219 + let draggingEventId: string | null = null; 1220 + 1221 + calendarGrid.addEventListener('dragstart', (e) => { 1222 + const pill = (e.target as HTMLElement).closest('[data-event-id]') as HTMLElement | null; 1223 + if (!pill) return; 1224 + draggingEventId = pill.dataset.eventId!; 1225 + e.dataTransfer!.effectAllowed = 'move'; 1226 + e.dataTransfer!.setData('text/plain', draggingEventId); 1227 + pill.classList.add('cal-dragging'); 1228 + }); 1229 + 1230 + calendarGrid.addEventListener('dragend', (e) => { 1231 + const pill = (e.target as HTMLElement).closest('[data-event-id]') as HTMLElement | null; 1232 + if (pill) pill.classList.remove('cal-dragging'); 1233 + calendarGrid.querySelectorAll('.cal-drag-over').forEach(el => el.classList.remove('cal-drag-over')); 1234 + draggingEventId = null; 1235 + }); 1236 + 1237 + calendarGrid.addEventListener('dragover', (e) => { 1238 + if (!draggingEventId) return; 1239 + const dayCell = (e.target as HTMLElement).closest('.cal-day-cell') as HTMLElement | null; 1240 + if (dayCell) { 1241 + e.preventDefault(); 1242 + e.dataTransfer!.dropEffect = 'move'; 1243 + // Highlight drop target 1244 + calendarGrid.querySelectorAll('.cal-drag-over').forEach(el => el.classList.remove('cal-drag-over')); 1245 + dayCell.classList.add('cal-drag-over'); 1246 + } 1247 + }); 1248 + 1249 + calendarGrid.addEventListener('drop', (e) => { 1250 + if (!draggingEventId) return; 1251 + const dayCell = (e.target as HTMLElement).closest('.cal-day-cell') as HTMLElement | null; 1252 + if (!dayCell) return; 1253 + e.preventDefault(); 1254 + calendarGrid.querySelectorAll('.cal-drag-over').forEach(el => el.classList.remove('cal-drag-over')); 1255 + 1256 + const newDate = dayCell.dataset.date!; 1257 + const evt = state.events.find(ev => ev.id === draggingEventId); 1258 + if (!evt || evt.date === newDate) { 1259 + draggingEventId = null; 1260 + return; 1261 + } 1262 + 1263 + // Calculate the date offset for multi-day events 1264 + const updated: CalendarEvent = { ...evt, date: newDate, updatedAt: Date.now() }; 1265 + if (evt.endDate) { 1266 + const startMs = parseEventDate(evt.date).getTime(); 1267 + const endMs = parseEventDate(evt.endDate).getTime(); 1268 + const durationMs = endMs - startMs; 1269 + const newStartMs = parseEventDate(newDate).getTime(); 1270 + const newEnd = new Date(newStartMs + durationMs); 1271 + updated.endDate = formatDate(newEnd); 1272 + } 1273 + 1274 + updateEventInYjs(updated); 1275 + draggingEventId = null; 1276 + showToast(`Moved "${evt.title}" to ${newDate}`, 2000); 1128 1277 }); 1129 1278 1130 1279 // ---------------------------------------------------------------------------
+46
src/css/app.css
··· 9326 9326 } 9327 9327 9328 9328 .calendar-main { 9329 + position: relative; 9329 9330 flex: 1; 9330 9331 display: flex; 9331 9332 flex-direction: column; ··· 9740 9741 border-radius: 0 var(--radius-sm) var(--radius-sm) 0; 9741 9742 margin-left: 0; 9742 9743 } 9744 + 9745 + /* Drag-and-drop event rescheduling */ 9746 + .cal-event-pill.cal-dragging { 9747 + opacity: 0.4; 9748 + } 9749 + .cal-day-cell.cal-drag-over { 9750 + background: oklch(0.85 0.08 220 / 0.3); 9751 + outline: 2px dashed oklch(0.6 0.1 220); 9752 + outline-offset: -2px; 9753 + } 9754 + [data-theme="dark"] .cal-day-cell.cal-drag-over { 9755 + background: oklch(0.3 0.06 220 / 0.4); 9756 + outline-color: oklch(0.55 0.1 220); 9757 + } 9758 + 9759 + /* Drop overlay for .ics file import */ 9760 + .cal-drop-overlay { 9761 + position: absolute; 9762 + inset: 0; 9763 + background: oklch(0.95 0.02 220 / 0.92); 9764 + display: flex; 9765 + align-items: center; 9766 + justify-content: center; 9767 + z-index: 100; 9768 + border: 3px dashed oklch(0.6 0.1 220); 9769 + border-radius: var(--radius-lg); 9770 + pointer-events: none; 9771 + } 9772 + [data-theme="dark"] .cal-drop-overlay { 9773 + background: oklch(0.15 0.02 220 / 0.92); 9774 + border-color: oklch(0.5 0.1 220); 9775 + } 9776 + .cal-drop-overlay-inner { 9777 + display: flex; 9778 + flex-direction: column; 9779 + align-items: center; 9780 + gap: 0.5rem; 9781 + font-size: 1.1rem; 9782 + font-weight: 600; 9783 + color: oklch(0.4 0.08 220); 9784 + } 9785 + [data-theme="dark"] .cal-drop-overlay-inner { 9786 + color: oklch(0.8 0.06 220); 9787 + } 9788 + .cal-drop-icon { font-size: 2.5rem; } 9743 9789 9744 9790 .cal-more-link { 9745 9791 display: block;