@luckydye/calendar 1.1.0 → 1.1.2

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