@luckydye/calendar 1.1.1 → 1.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1025 @@
1
+ import { css, html, LitElement } from "lit";
2
+ import type { CalendarSource } from "./CalendarIntegration.js";
3
+ import { authenticateWithGoogle } from "./GoogleCalendarSource.js";
4
+ import { InhouseBookingSource } from "./InhouseBookingSource.js";
5
+ import { CalDAVSource as CalDAVSourceClass } from "./CalDAVSource.js";
6
+
7
+ interface CalDAVSourceConfig extends CalendarSource {
8
+ type: "caldav";
9
+ credentials: {
10
+ serverUrl: string;
11
+ username: string;
12
+ password: string;
13
+ };
14
+ locked?: boolean;
15
+ }
16
+
17
+ interface ICalSource extends CalendarSource {
18
+ type: "ical";
19
+ credentials: {
20
+ url: string;
21
+ };
22
+ locked?: boolean;
23
+ }
24
+
25
+ interface GoogleSource extends CalendarSource {
26
+ type: "google";
27
+ credentials: {
28
+ accessToken: string;
29
+ refreshToken?: string;
30
+ tokenExpiry?: string;
31
+ calendarId?: string;
32
+ };
33
+ locked?: boolean;
34
+ }
35
+
36
+ interface InhouseSource extends CalendarSource {
37
+ type: "inhouse";
38
+ credentials: {
39
+ sessionCookie: string;
40
+ employeeId: string;
41
+ unitId?: string;
42
+ };
43
+ locked?: boolean;
44
+ }
45
+
46
+ type ConfigurableSource = CalDAVSourceConfig | ICalSource | GoogleSource | InhouseSource;
47
+
48
+ export class CalDAVConfigElement extends LitElement {
49
+ static styles = css`
50
+ :host {
51
+ display: block;
52
+ font-family: -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
53
+ color: var(--text-primary, rgba(255, 255, 255, 0.9));
54
+ }
55
+
56
+ .container {
57
+ background: var(--bg-elevated, rgba(30, 30, 30, 0.95));
58
+ border: 1px solid var(--grid-color, rgba(255, 255, 255, 0.1));
59
+ border-radius: var(--border-radius-lg, 8px);
60
+ padding: 16px;
61
+ box-sizing: border-box;
62
+ min-width: 400px;
63
+ max-width: 500px;
64
+ height: 100%;
65
+ }
66
+
67
+ .header {
68
+ display: flex;
69
+ justify-content: space-between;
70
+ align-items: center;
71
+ margin-bottom: 16px;
72
+ padding-bottom: 12px;
73
+ border-bottom: 1px solid var(--grid-color, rgba(255, 255, 255, 0.1));
74
+ }
75
+
76
+ .title {
77
+ font-size: 16px;
78
+ font-weight: 600;
79
+ color: var(--text-primary, rgba(255, 255, 255, 0.9));
80
+ }
81
+
82
+ .close-btn {
83
+ background: none;
84
+ border: none;
85
+ color: var(--text-muted, rgba(255, 255, 255, 0.5));
86
+ font-size: 20px;
87
+ cursor: pointer;
88
+ padding: 4px;
89
+ line-height: 1;
90
+ }
91
+
92
+ .close-btn:hover {
93
+ color: var(--text-primary, rgba(255, 255, 255, 0.9));
94
+ }
95
+
96
+ .sources-list {
97
+ display: flex;
98
+ flex-direction: column;
99
+ gap: 12px;
100
+ margin-bottom: 16px;
101
+ overflow-y: auto;
102
+ max-height: calc(100% - 120px);
103
+ }
104
+
105
+ .source-item {
106
+ flex: none;
107
+ background: var(--bg-item, rgba(255, 255, 255, 0.05));
108
+ border: 1px solid var(--grid-color, rgba(255, 255, 255, 0.1));
109
+ border-radius: var(--border-radius, 6px);
110
+ padding: 12px;
111
+ overflow: hidden;
112
+ }
113
+
114
+ .source-header {
115
+ display: flex;
116
+ align-items: center;
117
+ gap: 8px;
118
+ margin-bottom: 8px;
119
+ }
120
+
121
+ .source-color {
122
+ width: 12px;
123
+ height: 12px;
124
+ border-radius: var(--border-radius-sm, 2px);
125
+ flex-shrink: 0;
126
+ }
127
+
128
+ .source-name {
129
+ flex: 1;
130
+ font-weight: 500;
131
+ color: var(--text-primary, rgba(255, 255, 255, 0.9));
132
+ font-size: 14px;
133
+ }
134
+
135
+ .source-enabled {
136
+ cursor: pointer;
137
+ }
138
+
139
+ .source-locked {
140
+ cursor: pointer;
141
+ margin-left: 8px;
142
+ }
143
+
144
+ .source-actions {
145
+ display: flex;
146
+ gap: 8px;
147
+ }
148
+
149
+ .icon-btn {
150
+ background: none;
151
+ border: none;
152
+ color: var(--text-muted, rgba(255, 255, 255, 0.5));
153
+ cursor: pointer;
154
+ padding: 4px;
155
+ font-size: 14px;
156
+ line-height: 1;
157
+ }
158
+
159
+ .icon-btn:hover {
160
+ color: var(--text-primary, rgba(255, 255, 255, 0.9));
161
+ }
162
+
163
+ .source-url {
164
+ font-size: 12px;
165
+ color: var(--text-muted, rgba(255, 255, 255, 0.4));
166
+ }
167
+
168
+ .empty-state {
169
+ text-align: center;
170
+ padding: 24px;
171
+ color: var(--text-muted, rgba(255, 255, 255, 0.4));
172
+ font-size: 14px;
173
+ }
174
+
175
+ .add-btn {
176
+ width: 100%;
177
+ padding: 10px;
178
+ background: var(--bg-button-hover, rgba(255, 255, 255, 0.1));
179
+ border: 1px dashed var(--grid-color-hover, rgba(255, 255, 255, 0.3));
180
+ border-radius: var(--border-radius, 6px);
181
+ color: var(--text-secondary, rgba(255, 255, 255, 0.7));
182
+ cursor: pointer;
183
+ font-size: 14px;
184
+ transition: all 0.15s;
185
+ }
186
+
187
+ .add-btn:hover {
188
+ background: var(--bg-item-hover, rgba(255, 255, 255, 0.15));
189
+ border-color: var(--grid-color-strong, rgba(255, 255, 255, 0.5));
190
+ color: var(--text-primary, rgba(255, 255, 255, 0.9));
191
+ }
192
+
193
+ .form {
194
+ display: flex;
195
+ flex-direction: column;
196
+ gap: 12px;
197
+ }
198
+
199
+ .form-group {
200
+ display: flex;
201
+ flex-direction: column;
202
+ gap: 4px;
203
+ }
204
+
205
+ .form-label {
206
+ font-size: 12px;
207
+ color: var(--text-muted, rgba(255, 255, 255, 0.5));
208
+ text-transform: uppercase;
209
+ letter-spacing: 0.5px;
210
+ }
211
+
212
+ .form-input {
213
+ background: var(--bg-input, rgba(0, 0, 0, 0.3));
214
+ border: 1px solid var(--grid-color, rgba(255, 255, 255, 0.1));
215
+ border-radius: var(--border-radius-sm, 4px);
216
+ padding: 8px 12px;
217
+ color: var(--text-primary, rgba(255, 255, 255, 0.9));
218
+ font-size: 14px;
219
+ outline: none;
220
+ }
221
+
222
+ .form-input:focus {
223
+ border-color: var(--grid-color-strong, rgba(255, 255, 255, 0.3));
224
+ background: var(--bg-input-focus, rgba(0, 0, 0, 0.5));
225
+ }
226
+
227
+ .color-picker {
228
+ display: flex;
229
+ gap: 8px;
230
+ flex-wrap: wrap;
231
+ }
232
+
233
+ .color-option {
234
+ width: 28px;
235
+ height: 28px;
236
+ border-radius: var(--border-radius-sm, 4px);
237
+ cursor: pointer;
238
+ border: 2px solid transparent;
239
+ transition: transform 0.15s;
240
+ }
241
+
242
+ .color-option:hover {
243
+ transform: scale(1.1);
244
+ }
245
+
246
+ .color-option.selected {
247
+ border-color: var(--text-primary, rgba(255, 255, 255, 0.8));
248
+ }
249
+
250
+ .form-actions {
251
+ display: flex;
252
+ gap: 8px;
253
+ justify-content: flex-end;
254
+ margin-top: 8px;
255
+ }
256
+
257
+ .btn {
258
+ padding: 8px 16px;
259
+ border-radius: var(--border-radius-sm, 4px);
260
+ font-size: 13px;
261
+ cursor: pointer;
262
+ transition: all 0.15s;
263
+ }
264
+
265
+ .btn-primary {
266
+ background: var(--accent-primary, rgba(100, 150, 255, 0.8));
267
+ border: none;
268
+ color: var(--text-inverse, white);
269
+ }
270
+
271
+ .btn-primary:hover {
272
+ background: var(--accent-primary, rgba(100, 150, 255, 1));
273
+ }
274
+
275
+ .btn-secondary {
276
+ background: transparent;
277
+ border: 1px solid var(--grid-color-hover, rgba(255, 255, 255, 0.2));
278
+ color: var(--text-secondary, rgba(255, 255, 255, 0.7));
279
+ }
280
+
281
+ .btn-secondary:hover {
282
+ border-color: var(--grid-color-strong, rgba(255, 255, 255, 0.4));
283
+ color: var(--text-primary, rgba(255, 255, 255, 0.9));
284
+ }
285
+
286
+ .sync-status {
287
+ display: flex;
288
+ align-items: center;
289
+ gap: 8px;
290
+ margin-top: 12px;
291
+ padding-top: 12px;
292
+ border-top: 1px solid var(--grid-color, rgba(255, 255, 255, 0.1));
293
+ font-size: 12px;
294
+ color: var(--text-muted, rgba(255, 255, 255, 0.5));
295
+ }
296
+
297
+ .sync-spinner {
298
+ width: 14px;
299
+ height: 14px;
300
+ border: 2px solid var(--grid-color, rgba(255, 255, 255, 0.2));
301
+ border-top-color: var(--text-primary, rgba(255, 255, 255, 0.8));
302
+ border-radius: 50%;
303
+ animation: spin 1s linear infinite;
304
+ }
305
+
306
+ @keyframes spin {
307
+ to { transform: rotate(360deg); }
308
+ }
309
+
310
+ .sync-success {
311
+ color: var(--accent-success, #7cb342);
312
+ }
313
+
314
+ .sync-error {
315
+ color: var(--accent-error, #e53935);
316
+ }
317
+
318
+ .google-auth-section {
319
+ display: flex;
320
+ flex-direction: column;
321
+ gap: 12px;
322
+ padding: 16px;
323
+ background: var(--bg-item, rgba(255, 255, 255, 0.05));
324
+ border: 1px solid var(--grid-color, rgba(255, 255, 255, 0.1));
325
+ border-radius: var(--border-radius, 6px);
326
+ }
327
+
328
+ .google-login-btn {
329
+ display: flex;
330
+ align-items: center;
331
+ justify-content: center;
332
+ gap: 10px;
333
+ padding: 12px 16px;
334
+ background: white;
335
+ border: 1px solid #dadce0;
336
+ border-radius: 4px;
337
+ color: #3c4043;
338
+ font-family: "Google Sans", Roboto, Arial, sans-serif;
339
+ font-size: 14px;
340
+ font-weight: 500;
341
+ cursor: pointer;
342
+ transition: box-shadow 0.15s;
343
+ }
344
+
345
+ .google-login-btn:hover {
346
+ box-shadow: 0 1px 2px 0 rgba(60, 64, 67, 0.3), 0 1px 3px 1px rgba(60, 64, 67, 0.15);
347
+ }
348
+
349
+ .google-login-btn:disabled {
350
+ opacity: 0.6;
351
+ cursor: not-allowed;
352
+ }
353
+
354
+ .google-logo {
355
+ width: 18px;
356
+ height: 18px;
357
+ }
358
+
359
+ .auth-status {
360
+ font-size: 13px;
361
+ color: var(--text-muted, rgba(255, 255, 255, 0.5));
362
+ }
363
+
364
+ .auth-status.authenticated {
365
+ color: var(--accent-success, #7cb342);
366
+ }
367
+
368
+ .auth-status.error {
369
+ color: var(--accent-error, #e53935);
370
+ }
371
+
372
+ .client-id-input {
373
+ font-size: 12px;
374
+ }
375
+ `;
376
+
377
+ sources: ConfigurableSource[] = [];
378
+ isAdding = false;
379
+ editingId: string | null = null;
380
+ isGoogleAuthenticating = false;
381
+ googleAuthError: string | null = null;
382
+
383
+ private formData: Partial<ConfigurableSource> = {};
384
+
385
+ static get properties() {
386
+ return {
387
+ sources: { type: Array },
388
+ isAdding: { type: Boolean },
389
+ editingId: { type: String },
390
+ isGoogleAuthenticating: { type: Boolean },
391
+ googleAuthError: { type: String },
392
+ };
393
+ }
394
+
395
+ constructor() {
396
+ super();
397
+ this.loadSources();
398
+
399
+ // Check for OAuth callback (authorization code flow)
400
+ if (typeof window !== 'undefined' && window.location.search) {
401
+ const params = new URLSearchParams(window.location.search);
402
+ const code = params.get('code');
403
+ const error = params.get('error');
404
+ const state = params.get('state');
405
+
406
+ if (code || error) {
407
+ // Send the result to the opener window
408
+ if (window.opener) {
409
+ window.opener.postMessage({
410
+ type: 'google-oauth-callback',
411
+ code: code,
412
+ error: error,
413
+ receivedState: state,
414
+ }, window.location.origin);
415
+ window.close();
416
+ }
417
+ }
418
+ }
419
+ }
420
+
421
+ connectedCallback() {
422
+ super.connectedCallback();
423
+ this.loadSources();
424
+ }
425
+
426
+ loadSources() {
427
+ const saved = localStorage.getItem("caldav-sources");
428
+ if (saved) {
429
+ try {
430
+ this.sources = JSON.parse(saved);
431
+ } catch {
432
+ this.sources = [];
433
+ }
434
+ }
435
+
436
+ }
437
+
438
+ saveSources() {
439
+ localStorage.setItem("caldav-sources", JSON.stringify(this.sources));
440
+ this.dispatchEvent(new CustomEvent("sources-changed", {
441
+ detail: { sources: this.sources },
442
+ bubbles: true,
443
+ }));
444
+ this.requestUpdate();
445
+ }
446
+
447
+ addSource() {
448
+ this.isAdding = true;
449
+ this.editingId = null;
450
+ this.formData = {
451
+ type: "caldav",
452
+ color: "#FF6E68",
453
+ enabled: true,
454
+ };
455
+ this.requestUpdate();
456
+ }
457
+
458
+ editSource(source: ConfigurableSource) {
459
+ this.isAdding = false;
460
+ this.editingId = source.id;
461
+ this.formData = { ...source };
462
+ this.requestUpdate();
463
+ }
464
+
465
+ deleteSource(id: string) {
466
+ this.sources = this.sources.filter((s) => s.id !== id);
467
+ this.saveSources();
468
+ }
469
+
470
+ toggleEnabled(source: ConfigurableSource) {
471
+ source.enabled = !source.enabled;
472
+ this.saveSources();
473
+ }
474
+
475
+ toggleLocked(source: ConfigurableSource) {
476
+ source.locked = !source.locked;
477
+ this.saveSources();
478
+ }
479
+
480
+ saveForm() {
481
+ if (!this.formData.name?.trim()) {
482
+ alert('Please enter a calendar name');
483
+ return;
484
+ }
485
+
486
+ if (!this.formData.type) {
487
+ alert('Please select a calendar type');
488
+ return;
489
+ }
490
+
491
+ let source: ConfigurableSource;
492
+
493
+ if (this.formData.type === "caldav") {
494
+ if (
495
+ !this.formData.credentials?.serverUrl ||
496
+ !this.formData.credentials?.username ||
497
+ !this.formData.credentials?.password
498
+ ) {
499
+ return;
500
+ }
501
+
502
+ source = {
503
+ id: this.editingId || crypto.randomUUID(),
504
+ name: this.formData.name,
505
+ type: "caldav",
506
+ credentials: {
507
+ serverUrl: this.formData.credentials.serverUrl,
508
+ username: this.formData.credentials.username,
509
+ password: this.formData.credentials.password,
510
+ },
511
+ color: this.formData.color || "#FF6E68",
512
+ enabled: this.formData.enabled ?? true,
513
+ locked: this.formData.locked ?? false,
514
+ } as CalDAVSourceConfig;
515
+ } else if (this.formData.type === "ical") {
516
+ if (!this.formData.credentials?.url) {
517
+ return;
518
+ }
519
+
520
+ source = {
521
+ id: this.editingId || crypto.randomUUID(),
522
+ name: this.formData.name,
523
+ type: "ical",
524
+ credentials: {
525
+ url: this.formData.credentials.url,
526
+ },
527
+ color: this.formData.color || "#FF6E68",
528
+ enabled: this.formData.enabled ?? true,
529
+ locked: this.formData.locked ?? false,
530
+ } as ICalSource;
531
+ } else if (this.formData.type === "google") {
532
+ if (!this.formData.credentials?.accessToken) {
533
+ alert('Please authenticate with Google before adding the calendar');
534
+ return;
535
+ }
536
+
537
+ source = {
538
+ id: this.editingId || crypto.randomUUID(),
539
+ name: this.formData.name,
540
+ type: "google",
541
+ credentials: {
542
+ accessToken: this.formData.credentials.accessToken,
543
+ refreshToken: this.formData.credentials.refreshToken,
544
+ tokenExpiry: this.formData.credentials.tokenExpiry,
545
+ calendarId: this.formData.credentials.calendarId || "primary",
546
+ },
547
+ color: this.formData.color || "#4285F4",
548
+ enabled: this.formData.enabled ?? true,
549
+ locked: this.formData.locked ?? false,
550
+ } as GoogleSource;
551
+ } else if (this.formData.type === "inhouse") {
552
+ if (!this.formData.credentials?.sessionCookie || !this.formData.credentials?.employeeId) {
553
+ alert('Please enter session cookie and employee ID');
554
+ return;
555
+ }
556
+
557
+ source = {
558
+ id: this.editingId || crypto.randomUUID(),
559
+ name: this.formData.name,
560
+ type: "inhouse",
561
+ credentials: {
562
+ sessionCookie: this.formData.credentials.sessionCookie,
563
+ employeeId: this.formData.credentials.employeeId,
564
+ unitId: this.formData.credentials.unitId,
565
+ },
566
+ color: this.formData.color || "#FF6E68",
567
+ enabled: this.formData.enabled ?? true,
568
+ locked: this.formData.locked ?? false,
569
+ } as InhouseSource;
570
+ } else {
571
+ return;
572
+ }
573
+
574
+ if (this.editingId) {
575
+ const index = this.sources.findIndex((s) => s.id === this.editingId);
576
+ if (index >= 0) {
577
+ this.sources[index] = source;
578
+ }
579
+ } else {
580
+ this.sources.push(source);
581
+ }
582
+
583
+ this.isAdding = false;
584
+ this.editingId = null;
585
+ this.formData = {};
586
+ this.googleAuthError = null;
587
+ this.saveSources();
588
+ this.requestUpdate();
589
+ }
590
+
591
+ cancelForm() {
592
+ this.isAdding = false;
593
+ this.editingId = null;
594
+ this.formData = {};
595
+ this.googleAuthError = null;
596
+ this.isGoogleAuthenticating = false;
597
+ this.requestUpdate();
598
+ }
599
+
600
+ updateForm(field: string, value: string | boolean) {
601
+ if (field === "serverUrl" || field === "username" || field === "password" || field === "url" ||
602
+ field === "accessToken" || field === "refreshToken" || field === "tokenExpiry" || field === "calendarId" ||
603
+ field === "sessionCookie" || field === "employeeId" || field === "unitId") {
604
+ this.formData = {
605
+ ...this.formData,
606
+ credentials: {
607
+ ...this.formData.credentials,
608
+ [field]: value,
609
+ } as ConfigurableSource["credentials"],
610
+ };
611
+ } else {
612
+ this.formData = { ...this.formData, [field]: value };
613
+ }
614
+ this.requestUpdate();
615
+ }
616
+
617
+ async signInWithGoogle() {
618
+ this.isGoogleAuthenticating = true;
619
+ this.googleAuthError = null;
620
+ this.requestUpdate();
621
+
622
+ try {
623
+ // Load Google OAuth credentials from public directory
624
+ const credentialsResponse = await fetch('/credentials_google.json');
625
+ if (!credentialsResponse.ok) {
626
+ throw new Error('Google OAuth credentials file not found. Please add credentials_google.json to the public directory.');
627
+ }
628
+
629
+ const credentials = await credentialsResponse.json();
630
+ const clientId = credentials.installed?.client_id || credentials.web?.client_id;
631
+ const clientSecret = credentials.installed?.client_secret || credentials.web?.client_secret;
632
+
633
+ if (!clientId || !clientSecret) {
634
+ throw new Error('Invalid credentials file format. Expected client_id and client_secret.');
635
+ }
636
+
637
+ const tokens = await authenticateWithGoogle(clientId, clientSecret);
638
+
639
+ // Fetch the list of calendars from Google
640
+ const calendarListResponse = await fetch(
641
+ 'https://www.googleapis.com/calendar/v3/users/me/calendarList',
642
+ {
643
+ headers: {
644
+ Authorization: `Bearer ${tokens.accessToken}`,
645
+ },
646
+ }
647
+ );
648
+
649
+ if (!calendarListResponse.ok) {
650
+ throw new Error('Failed to fetch calendar list from Google');
651
+ }
652
+
653
+ const calendarList = await calendarListResponse.json();
654
+
655
+ // Create a source for each calendar
656
+ for (const calendar of calendarList.items || []) {
657
+ const newSource: GoogleSource = {
658
+ id: crypto.randomUUID(),
659
+ name: calendar.summary || 'Untitled Calendar',
660
+ type: 'google',
661
+ credentials: {
662
+ accessToken: tokens.accessToken,
663
+ refreshToken: tokens.refreshToken,
664
+ tokenExpiry: tokens.expiry,
665
+ calendarId: calendar.id,
666
+ },
667
+ color: calendar.backgroundColor || '#4285F4',
668
+ enabled: true,
669
+ };
670
+
671
+ this.sources.push(newSource);
672
+ }
673
+
674
+ this.saveSources();
675
+ this.isAdding = false;
676
+ this.formData = {};
677
+ this.isGoogleAuthenticating = false;
678
+ this.requestUpdate();
679
+ } catch (error) {
680
+ this.isGoogleAuthenticating = false;
681
+ this.googleAuthError = error instanceof Error ? error.message : "Authentication failed";
682
+ this.requestUpdate();
683
+ }
684
+ }
685
+
686
+ close() {
687
+ this.dispatchEvent(new CustomEvent("close", { bubbles: true }));
688
+ }
689
+
690
+ render() {
691
+ const colors = [
692
+ "#FF6E68",
693
+ "#FFB800",
694
+ "#83D754",
695
+ "#0095FD",
696
+ "#9FC6E7",
697
+ "#888082",
698
+ "#E91E63",
699
+ "#9C27B0",
700
+ "#673AB7",
701
+ "#3F51B5",
702
+ "#2196F3",
703
+ "#00BCD4",
704
+ "#009688",
705
+ "#4CAF50",
706
+ "#8BC34A",
707
+ "#CDDC39",
708
+ "#FFEB3B",
709
+ "#FFC107",
710
+ "#FF9800",
711
+ "#FF5722",
712
+ "#795548",
713
+ ];
714
+
715
+ return html`
716
+ <div class="container">
717
+ <div class="header">
718
+ <span class="title">Calendar Sources</span>
719
+ <button class="close-btn" @click=${this.close}>×</button>
720
+ </div>
721
+
722
+ ${this.isAdding || this.editingId
723
+ ? this.renderForm(colors)
724
+ : this.renderSourcesList()}
725
+ </div>
726
+ `;
727
+ }
728
+
729
+ renderSourcesList() {
730
+ if (this.sources.length === 0) {
731
+ return html`
732
+ <div class="empty-state">
733
+ No calendar sources configured
734
+ </div>
735
+ <button class="add-btn" @click=${this.addSource}>
736
+ + Add Calendar Source
737
+ </button>
738
+ `;
739
+ }
740
+
741
+ console.log(this.sources);
742
+
743
+ return html`
744
+ <div class="sources-list">
745
+ ${this.sources.map(
746
+ (source) => {
747
+ let urlDisplay: string;
748
+ if (source.type === "caldav") {
749
+ urlDisplay = (source.credentials as CalDAVSourceConfig["credentials"]).serverUrl;
750
+ } else if (source.type === "ical") {
751
+ urlDisplay = (source.credentials as ICalSource["credentials"]).url;
752
+ } else if (source.type === "google") {
753
+ urlDisplay = (source.credentials as GoogleSource["credentials"]).calendarId || "primary";
754
+ } else if (source.type === "inhouse") {
755
+ urlDisplay = `Employee: ${(source.credentials as InhouseSource["credentials"]).employeeId}`;
756
+ } else {
757
+ urlDisplay = "Unknown source";
758
+ }
759
+
760
+ return html`
761
+ <div class="source-item">
762
+ <div class="source-header">
763
+ <div
764
+ class="source-color"
765
+ style="background: ${source.color}"
766
+ ></div>
767
+ <span class="source-name">${source.name}</span>
768
+ <input
769
+ type="checkbox"
770
+ class="source-enabled"
771
+ .checked=${source.enabled}
772
+ @change=${() => this.toggleEnabled(source)}
773
+ title="${source.enabled ? "Disable" : "Enable"} sync"
774
+ />
775
+ <input
776
+ type="checkbox"
777
+ class="source-locked"
778
+ .checked=${source.locked}
779
+ @change=${() => this.toggleLocked(source)}
780
+ title="${source.locked ? "Unlock (allow editing)" : "Lock (read-only)"}"
781
+ />
782
+ <div class="source-actions">
783
+ <button
784
+ class="icon-btn"
785
+ @click=${() => this.editSource(source)}
786
+ title="Edit"
787
+ >
788
+
789
+ </button>
790
+ <button
791
+ class="icon-btn"
792
+ @click=${() => this.deleteSource(source.id)}
793
+ title="Delete"
794
+ >
795
+ 🗑
796
+ </button>
797
+ </div>
798
+ </div>
799
+ <div class="source-url">${source.type.toUpperCase()}: ${urlDisplay || "No URL"}</div>
800
+ </div>
801
+ `;
802
+ }
803
+ )}
804
+ </div>
805
+ <button class="add-btn" @click=${this.addSource}>
806
+ + Add Calendar Source
807
+ </button>
808
+ `;
809
+ }
810
+
811
+ renderForm(colors: string[]) {
812
+ const isEditing = this.editingId !== null;
813
+ const sourceType = this.formData.type || "caldav";
814
+
815
+ return html`
816
+ <div class="form">
817
+ <div class="form-group">
818
+ <label class="form-label">Type</label>
819
+ <select
820
+ class="form-input"
821
+ .value=${sourceType}
822
+ @change=${(e: Event) => {
823
+ const type = (e.target as HTMLSelectElement).value;
824
+ this.updateForm("type", type);
825
+ // Set default color based on type
826
+ if (type === "google" && !this.formData.color) {
827
+ this.updateForm("color", "#4285F4");
828
+ } else if (type === "caldav" && !this.formData.color) {
829
+ this.updateForm("color", "#FF6E68");
830
+ }
831
+ }}
832
+ ?disabled=${isEditing}
833
+ >
834
+ <option value="caldav">CalDAV (with credentials)</option>
835
+ <option value="ical">iCal URL</option>
836
+ <option value="google">Google Calendar</option>
837
+ <option value="inhouse">Inhouse Booking System</option>
838
+ </select>
839
+ </div>
840
+
841
+ <div class="form-group">
842
+ <label class="form-label">Name</label>
843
+ <input
844
+ class="form-input"
845
+ type="text"
846
+ placeholder="My Calendar"
847
+ .value=${this.formData.name || ""}
848
+ @input=${(e: Event) =>
849
+ this.updateForm("name", (e.target as HTMLInputElement).value)}
850
+ />
851
+ </div>
852
+
853
+ ${sourceType === "caldav"
854
+ ? html`
855
+ <div class="form-group">
856
+ <label class="form-label">Server URL</label>
857
+ <input
858
+ class="form-input"
859
+ type="text"
860
+ placeholder="https://mail.example.com/caldav/users/username/"
861
+ .value=${this.formData.credentials?.serverUrl || ""}
862
+ @input=${(e: Event) =>
863
+ this.updateForm("serverUrl", (e.target as HTMLInputElement).value)}
864
+ />
865
+ </div>
866
+
867
+ <div class="form-group">
868
+ <label class="form-label">Username</label>
869
+ <input
870
+ class="form-input"
871
+ type="text"
872
+ .value=${this.formData.credentials?.username || ""}
873
+ @input=${(e: Event) =>
874
+ this.updateForm("username", (e.target as HTMLInputElement).value)}
875
+ />
876
+ </div>
877
+
878
+ <div class="form-group">
879
+ <label class="form-label">Password</label>
880
+ <input
881
+ class="form-input"
882
+ type="password"
883
+ .value=${this.formData.credentials?.password || ""}
884
+ @input=${(e: Event) =>
885
+ this.updateForm("password", (e.target as HTMLInputElement).value)}
886
+ />
887
+ </div>
888
+ `
889
+ : sourceType === "ical"
890
+ ? html`
891
+ <div class="form-group">
892
+ <label class="form-label">iCal URL</label>
893
+ <input
894
+ class="form-input"
895
+ type="text"
896
+ placeholder="https://example.com/calendar.ics"
897
+ .value=${this.formData.credentials?.url || ""}
898
+ @input=${(e: Event) =>
899
+ this.updateForm("url", (e.target as HTMLInputElement).value)}
900
+ />
901
+ </div>
902
+ `
903
+ : sourceType === "google"
904
+ ? html`
905
+ <div class="google-auth-section">
906
+ ${this.formData.credentials?.accessToken
907
+ ? html`
908
+ <div class="auth-status authenticated">
909
+ ✓ Authenticated with Google
910
+ </div>
911
+ `
912
+ : html`
913
+ <button
914
+ class="google-login-btn"
915
+ @click=${this.signInWithGoogle}
916
+ ?disabled=${this.isGoogleAuthenticating}
917
+ >
918
+ ${this.isGoogleAuthenticating
919
+ ? html`<span>Signing in...</span>`
920
+ : html`
921
+ <svg class="google-logo" viewBox="0 0 24 24">
922
+ <path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
923
+ <path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
924
+ <path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
925
+ <path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
926
+ </svg>
927
+ <span>Sign in with Google</span>
928
+ `}
929
+ </button>
930
+ `}
931
+ ${this.googleAuthError
932
+ ? html`<div class="auth-status error">${this.googleAuthError}</div>`
933
+ : null}
934
+ </div>
935
+
936
+ <div class="form-group">
937
+ <label class="form-label">Calendar ID (optional)</label>
938
+ <input
939
+ class="form-input"
940
+ type="text"
941
+ placeholder="primary"
942
+ .value=${this.formData.credentials?.calendarId || ""}
943
+ @input=${(e: Event) =>
944
+ this.updateForm("calendarId", (e.target as HTMLInputElement).value)}
945
+ />
946
+ </div>
947
+ `
948
+ : sourceType === "inhouse"
949
+ ? html`
950
+ <div class="form-group">
951
+ <label class="form-label">Session Cookie</label>
952
+ <input
953
+ class="form-input"
954
+ type="password"
955
+ placeholder="sessionid=abc123; csrftoken=xyz789"
956
+ .value=${this.formData.credentials?.sessionCookie || ""}
957
+ @input=${(e: Event) =>
958
+ this.updateForm("sessionCookie", (e.target as HTMLInputElement).value)}
959
+ />
960
+ <small style="color: var(--text-muted, rgba(255, 255, 255, 0.5)); font-size: 11px;">
961
+ Copy from browser DevTools after logging in
962
+ </small>
963
+ </div>
964
+
965
+ <div class="form-group">
966
+ <label class="form-label">Employee ID</label>
967
+ <input
968
+ class="form-input"
969
+ type="text"
970
+ placeholder="589"
971
+ .value=${this.formData.credentials?.employeeId || ""}
972
+ @input=${(e: Event) =>
973
+ this.updateForm("employeeId", (e.target as HTMLInputElement).value)}
974
+ />
975
+ </div>
976
+
977
+ <div class="form-group">
978
+ <label class="form-label">Unit ID (optional)</label>
979
+ <input
980
+ class="form-input"
981
+ type="text"
982
+ placeholder="3"
983
+ .value=${this.formData.credentials?.unitId || ""}
984
+ @input=${(e: Event) =>
985
+ this.updateForm("unitId", (e.target as HTMLInputElement).value)}
986
+ />
987
+ </div>
988
+ `
989
+ : null}
990
+
991
+ <div class="form-group">
992
+ <label class="form-label">Color</label>
993
+ <div class="color-picker">
994
+ ${colors.map(
995
+ (color) => html`
996
+ <div
997
+ class="color-option ${this.formData.color === color
998
+ ? "selected"
999
+ : ""}"
1000
+ style="background: ${color}"
1001
+ @click=${() => this.updateForm("color", color)}
1002
+ ></div>
1003
+ `
1004
+ )}
1005
+ </div>
1006
+ </div>
1007
+
1008
+ <div class="form-actions">
1009
+ <button class="btn btn-secondary" @click=${this.cancelForm}>
1010
+ Cancel
1011
+ </button>
1012
+ <button class="btn btn-primary" @click=${this.saveForm}>
1013
+ ${isEditing ? "Save" : "Add"}
1014
+ </button>
1015
+ </div>
1016
+ </div>
1017
+ `;
1018
+ }
1019
+ }
1020
+
1021
+ try {
1022
+ customElements.define("caldav-config", CalDAVConfigElement);
1023
+ } catch (error) {
1024
+ console.error("Failed to register custom element:", error);
1025
+ }