@atmo-dev/events-ui 0.1.0

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.
Files changed (113) hide show
  1. package/dist/DatePicker.svelte +231 -0
  2. package/dist/DatePicker.svelte.d.ts +11 -0
  3. package/dist/DateTimePicker.svelte +101 -0
  4. package/dist/DateTimePicker.svelte.d.ts +9 -0
  5. package/dist/EventAttendees.svelte +203 -0
  6. package/dist/EventAttendees.svelte.d.ts +13 -0
  7. package/dist/EventCard.svelte +131 -0
  8. package/dist/EventCard.svelte.d.ts +8 -0
  9. package/dist/EventComments.svelte +99 -0
  10. package/dist/EventComments.svelte.d.ts +6 -0
  11. package/dist/EventEditor.svelte +589 -0
  12. package/dist/EventEditor.svelte.d.ts +20 -0
  13. package/dist/EventRsvp.svelte +237 -0
  14. package/dist/EventRsvp.svelte.d.ts +17 -0
  15. package/dist/EventView.svelte +433 -0
  16. package/dist/EventView.svelte.d.ts +16 -0
  17. package/dist/ImageDropper.svelte +66 -0
  18. package/dist/ImageDropper.svelte.d.ts +7 -0
  19. package/dist/Map.svelte +27 -0
  20. package/dist/Map.svelte.d.ts +8 -0
  21. package/dist/PostToBlueskyModal.svelte +244 -0
  22. package/dist/PostToBlueskyModal.svelte.d.ts +22 -0
  23. package/dist/ShareModal.svelte +160 -0
  24. package/dist/ShareModal.svelte.d.ts +23 -0
  25. package/dist/ThemeApply.svelte +50 -0
  26. package/dist/ThemeApply.svelte.d.ts +7 -0
  27. package/dist/ThemeBackground.svelte +33 -0
  28. package/dist/ThemeBackground.svelte.d.ts +7 -0
  29. package/dist/ThemePicker.svelte +102 -0
  30. package/dist/ThemePicker.svelte.d.ts +7 -0
  31. package/dist/ThumbnailPresets.svelte +68 -0
  32. package/dist/ThumbnailPresets.svelte.d.ts +11 -0
  33. package/dist/TimePicker.svelte +188 -0
  34. package/dist/TimePicker.svelte.d.ts +9 -0
  35. package/dist/TimezonePicker.svelte +132 -0
  36. package/dist/TimezonePicker.svelte.d.ts +6 -0
  37. package/dist/VodPlayer.svelte +137 -0
  38. package/dist/VodPlayer.svelte.d.ts +14 -0
  39. package/dist/VodTranscript.svelte +72 -0
  40. package/dist/VodTranscript.svelte.d.ts +8 -0
  41. package/dist/atproto-helpers.d.ts +21 -0
  42. package/dist/atproto-helpers.js +61 -0
  43. package/dist/cal/helper.d.ts +1 -0
  44. package/dist/cal/helper.js +20 -0
  45. package/dist/cal/ical.d.ts +22 -0
  46. package/dist/cal/ical.js +188 -0
  47. package/dist/cal/sanitize.d.ts +3 -0
  48. package/dist/cal/sanitize.js +25 -0
  49. package/dist/contrail.d.ts +54 -0
  50. package/dist/contrail.js +22 -0
  51. package/dist/date-format.d.ts +22 -0
  52. package/dist/date-format.js +43 -0
  53. package/dist/editor/LinksSection.svelte +144 -0
  54. package/dist/editor/LinksSection.svelte.d.ts +10 -0
  55. package/dist/editor/LocationSection.svelte +215 -0
  56. package/dist/editor/LocationSection.svelte.d.ts +8 -0
  57. package/dist/editor/RecurringModal.svelte +270 -0
  58. package/dist/editor/RecurringModal.svelte.d.ts +30 -0
  59. package/dist/editor/ThemeSection.svelte +39 -0
  60. package/dist/editor/ThemeSection.svelte.d.ts +7 -0
  61. package/dist/editor/ThumbnailSection.svelte +219 -0
  62. package/dist/editor/ThumbnailSection.svelte.d.ts +13 -0
  63. package/dist/editor/adapter.d.ts +98 -0
  64. package/dist/editor/adapter.js +9 -0
  65. package/dist/editor/save.d.ts +42 -0
  66. package/dist/editor/save.js +154 -0
  67. package/dist/editor/types.d.ts +39 -0
  68. package/dist/editor/types.js +9 -0
  69. package/dist/event-types.d.ts +70 -0
  70. package/dist/event-types.js +11 -0
  71. package/dist/event-view/AddToCalendarButton.svelte +42 -0
  72. package/dist/event-view/AddToCalendarButton.svelte.d.ts +9 -0
  73. package/dist/event-view/EventBadges.svelte +20 -0
  74. package/dist/event-view/EventBadges.svelte.d.ts +7 -0
  75. package/dist/event-view/EventDateBlock.svelte +43 -0
  76. package/dist/event-view/EventDateBlock.svelte.d.ts +7 -0
  77. package/dist/event-view/EventHostedBy.svelte +63 -0
  78. package/dist/event-view/EventHostedBy.svelte.d.ts +16 -0
  79. package/dist/event-view/EventLinksList.svelte +37 -0
  80. package/dist/event-view/EventLinksList.svelte.d.ts +9 -0
  81. package/dist/event-view/EventLocationBlock.svelte +48 -0
  82. package/dist/event-view/EventLocationBlock.svelte.d.ts +7 -0
  83. package/dist/event-view/EventLocationMap.svelte +72 -0
  84. package/dist/event-view/EventLocationMap.svelte.d.ts +8 -0
  85. package/dist/event-view/ExternalRsvpNotice.svelte +44 -0
  86. package/dist/event-view/ExternalRsvpNotice.svelte.d.ts +6 -0
  87. package/dist/event-view/InviteShareFlow.svelte +177 -0
  88. package/dist/event-view/InviteShareFlow.svelte.d.ts +15 -0
  89. package/dist/event-view/StreamPlacePlayer.svelte +222 -0
  90. package/dist/event-view/StreamPlacePlayer.svelte.d.ts +8 -0
  91. package/dist/event-view/format.d.ts +26 -0
  92. package/dist/event-view/format.js +145 -0
  93. package/dist/index.d.ts +18 -0
  94. package/dist/index.js +18 -0
  95. package/dist/profile-url.d.ts +1 -0
  96. package/dist/profile-url.js +7 -0
  97. package/dist/theme.d.ts +9 -0
  98. package/dist/theme.js +22 -0
  99. package/dist/themes/Blobs.svelte +35 -0
  100. package/dist/themes/Blobs.svelte.d.ts +26 -0
  101. package/dist/themes/Butterflies.svelte +185 -0
  102. package/dist/themes/Butterflies.svelte.d.ts +3 -0
  103. package/dist/themes/Fireflies.svelte +134 -0
  104. package/dist/themes/Fireflies.svelte.d.ts +3 -0
  105. package/dist/themes/Kaleidoscope.svelte +177 -0
  106. package/dist/themes/Kaleidoscope.svelte.d.ts +3 -0
  107. package/dist/themes/Matrix.svelte +150 -0
  108. package/dist/themes/Matrix.svelte.d.ts +3 -0
  109. package/dist/themes/Stars.svelte +98 -0
  110. package/dist/themes/Stars.svelte.d.ts +3 -0
  111. package/dist/thumbnails/designs.d.ts +18 -0
  112. package/dist/thumbnails/designs.js +316 -0
  113. package/package.json +95 -0
@@ -0,0 +1,102 @@
1
+ <script lang="ts">
2
+ import { themeBackgrounds, type EventTheme } from './theme.js';
3
+
4
+ let {
5
+ theme = $bindable<EventTheme>({ name: 'minimal', accentColor: 'cyan', baseColor: 'mist' })
6
+ }: {
7
+ theme: EventTheme;
8
+ } = $props();
9
+
10
+ const bgKeys = Object.keys(themeBackgrounds);
11
+
12
+ const accentColors = [
13
+ { label: 'red', cls: 'bg-red-500' },
14
+ { label: 'orange', cls: 'bg-orange-500' },
15
+ { label: 'amber', cls: 'bg-amber-500' },
16
+ { label: 'yellow', cls: 'bg-yellow-500' },
17
+ { label: 'lime', cls: 'bg-lime-500' },
18
+ { label: 'green', cls: 'bg-green-500' },
19
+ { label: 'emerald', cls: 'bg-emerald-500' },
20
+ { label: 'teal', cls: 'bg-teal-500' },
21
+ { label: 'cyan', cls: 'bg-cyan-500' },
22
+ { label: 'sky', cls: 'bg-sky-500' },
23
+ { label: 'blue', cls: 'bg-blue-500' },
24
+ { label: 'indigo', cls: 'bg-indigo-500' },
25
+ { label: 'violet', cls: 'bg-violet-500' },
26
+ { label: 'purple', cls: 'bg-purple-500' },
27
+ { label: 'fuchsia', cls: 'bg-fuchsia-500' },
28
+ { label: 'pink', cls: 'bg-pink-500' },
29
+ { label: 'rose', cls: 'bg-rose-500' }
30
+ ];
31
+
32
+ const baseColors = [
33
+ { label: 'gray', cls: 'bg-gray-500' },
34
+ { label: 'stone', cls: 'bg-stone-500' },
35
+ { label: 'zinc', cls: 'bg-zinc-500' },
36
+ { label: 'neutral', cls: 'bg-neutral-500' },
37
+ { label: 'slate', cls: 'bg-slate-500' },
38
+ { label: 'olive', cls: 'bg-olive-500' },
39
+ { label: 'mauve', cls: 'bg-mauve-500' },
40
+ { label: 'mist', cls: 'bg-mist-500' },
41
+ { label: 'taupe', cls: 'bg-taupe-500' }
42
+ ];
43
+ </script>
44
+
45
+ <div class="flex flex-col gap-6">
46
+ <!-- Theme background -->
47
+ <div>
48
+ <p class="text-base-500 dark:text-base-400 mb-2 text-xs font-medium">Background style</p>
49
+ <div class="flex flex-wrap gap-2">
50
+ {#each bgKeys as key}
51
+ <button
52
+ type="button"
53
+ class="relative flex aspect-video w-24 cursor-pointer items-center justify-center overflow-hidden rounded-xl border-2 transition-colors
54
+ {theme.name === key
55
+ ? 'border-accent-500'
56
+ : 'border-base-200 dark:border-base-700 hover:border-accent-400 dark:hover:border-accent-500'}"
57
+ onclick={() => (theme = { ...theme, name: key })}
58
+ >
59
+ <span class="text-base-600 dark:text-base-400 text-xs font-medium">
60
+ {themeBackgrounds[key]}
61
+ </span>
62
+ </button>
63
+ {/each}
64
+ </div>
65
+ </div>
66
+
67
+ <!-- Accent color -->
68
+ <div>
69
+ <p class="text-base-500 dark:text-base-400 mb-2 text-xs font-medium">Accent color</p>
70
+ <div class="flex flex-wrap gap-2">
71
+ {#each accentColors as color}
72
+ <button
73
+ type="button"
74
+ aria-label="Accent color {color.label}"
75
+ class="size-7 cursor-pointer rounded-full border-2 transition-all {color.cls}
76
+ {theme.accentColor === color.label
77
+ ? 'border-white scale-110 ring-2 ring-base-400'
78
+ : 'border-transparent hover:scale-105'}"
79
+ onclick={() => (theme = { ...theme, accentColor: color.label })}
80
+ ></button>
81
+ {/each}
82
+ </div>
83
+ </div>
84
+
85
+ <!-- Base color -->
86
+ <div>
87
+ <p class="text-base-500 dark:text-base-400 mb-2 text-xs font-medium">Base color</p>
88
+ <div class="flex flex-wrap gap-2">
89
+ {#each baseColors as color}
90
+ <button
91
+ type="button"
92
+ aria-label="Base color {color.label}"
93
+ class="size-7 cursor-pointer rounded-full border-2 transition-all {color.cls}
94
+ {theme.baseColor === color.label
95
+ ? 'border-white scale-110 ring-2 ring-base-400'
96
+ : 'border-transparent hover:scale-105'}"
97
+ onclick={() => (theme = { ...theme, baseColor: color.label })}
98
+ ></button>
99
+ {/each}
100
+ </div>
101
+ </div>
102
+ </div>
@@ -0,0 +1,7 @@
1
+ import { type EventTheme } from './theme.js';
2
+ type $$ComponentProps = {
3
+ theme: EventTheme;
4
+ };
5
+ declare const ThemePicker: import("svelte").Component<$$ComponentProps, {}, "theme">;
6
+ type ThemePicker = ReturnType<typeof ThemePicker>;
7
+ export default ThemePicker;
@@ -0,0 +1,68 @@
1
+ <script lang="ts">
2
+ // @ts-nocheck
3
+ import { designs, resolveAccentColor } from './thumbnails/designs';
4
+ import { tick } from 'svelte';
5
+
6
+ let {
7
+ name = '',
8
+ dateStr = '',
9
+ accent = '',
10
+ seed = 1,
11
+ selected = $bindable<string | null>(null),
12
+ onselect
13
+ }: {
14
+ name?: string;
15
+ dateStr?: string;
16
+ accent?: string;
17
+ seed?: number;
18
+ selected?: string | null;
19
+ onselect?: () => void;
20
+ } = $props();
21
+
22
+ const presetKeys = Object.keys(designs);
23
+ const previewSize = 200;
24
+
25
+ let containerEl: HTMLDivElement | undefined = $state(undefined);
26
+
27
+ function renderAll() {
28
+ if (!containerEl) return;
29
+ const color = resolveAccentColor(accent);
30
+ const canvases = containerEl.querySelectorAll<HTMLCanvasElement>('canvas');
31
+ canvases.forEach((canvas) => {
32
+ const key = canvas.dataset.key!;
33
+ const ctx = canvas.getContext('2d');
34
+ if (!ctx) return;
35
+ canvas.width = previewSize;
36
+ canvas.height = previewSize;
37
+ designs[key](ctx, previewSize, previewSize, name || 'Event', dateStr, seed, color);
38
+ });
39
+ }
40
+
41
+ $effect(() => {
42
+ void name;
43
+ void dateStr;
44
+ void accent;
45
+ void seed;
46
+ void containerEl;
47
+ tick().then(renderAll);
48
+ });
49
+ </script>
50
+
51
+ <div class="flex flex-col gap-3">
52
+ <p class="text-base-500 dark:text-base-400 text-xs font-medium">Preset thumbnails</p>
53
+ <div class="grid grid-cols-3 gap-2" bind:this={containerEl}>
54
+ {#each presetKeys as key}
55
+ <button
56
+ type="button"
57
+ aria-label="Use {key} preset thumbnail"
58
+ class="aspect-square cursor-pointer overflow-hidden rounded-xl border-2 transition-colors
59
+ {selected === key
60
+ ? 'border-accent-500'
61
+ : 'border-base-200 dark:border-base-700 hover:border-accent-400 dark:hover:border-accent-500'}"
62
+ onclick={() => { selected = key; onselect?.(); }}
63
+ >
64
+ <canvas data-key={key} class="h-full w-full"></canvas>
65
+ </button>
66
+ {/each}
67
+ </div>
68
+ </div>
@@ -0,0 +1,11 @@
1
+ type $$ComponentProps = {
2
+ name?: string;
3
+ dateStr?: string;
4
+ accent?: string;
5
+ seed?: number;
6
+ selected?: string | null;
7
+ onselect?: () => void;
8
+ };
9
+ declare const ThumbnailPresets: import("svelte").Component<$$ComponentProps, {}, "selected">;
10
+ type ThumbnailPresets = ReturnType<typeof ThumbnailPresets>;
11
+ export default ThumbnailPresets;
@@ -0,0 +1,188 @@
1
+ <script lang="ts">
2
+ // @ts-nocheck
3
+ import { Popover } from 'bits-ui';
4
+ import { TimeField } from 'bits-ui';
5
+ import { Time } from '@internationalized/date';
6
+ import { untrack, tick } from 'svelte';
7
+
8
+ let {
9
+ value = $bindable(''),
10
+ required = false,
11
+ locale = 'en',
12
+ referenceTime = ''
13
+ }: {
14
+ value: string;
15
+ required?: boolean;
16
+ locale?: string;
17
+ referenceTime?: string;
18
+ } = $props();
19
+
20
+ let isOpen = $state(false);
21
+ let listEl: HTMLDivElement | undefined = $state(undefined);
22
+ let internalValue: Time | undefined = $state(undefined);
23
+
24
+ function parseTimeStr(str: string): Time | undefined {
25
+ if (!str) return undefined;
26
+ const [hourStr, minuteStr] = str.split(':');
27
+ const hour = parseInt(hourStr, 10);
28
+ const minute = parseInt(minuteStr, 10);
29
+ if (isNaN(hour) || isNaN(minute)) return undefined;
30
+ return new Time(hour, minute);
31
+ }
32
+
33
+ function formatTimeStr(t: Time): string {
34
+ const h = String(t.hour).padStart(2, '0');
35
+ const m = String(t.minute).padStart(2, '0');
36
+ return `${h}:${m}`;
37
+ }
38
+
39
+ $effect(() => {
40
+ const parsed = parseTimeStr(value);
41
+ untrack(() => {
42
+ if (parsed) {
43
+ if (
44
+ !internalValue ||
45
+ parsed.hour !== internalValue.hour ||
46
+ parsed.minute !== internalValue.minute
47
+ ) {
48
+ internalValue = parsed;
49
+ }
50
+ } else {
51
+ internalValue = undefined;
52
+ }
53
+ });
54
+ });
55
+
56
+ function handleValueChange(newVal: Time | undefined) {
57
+ if (newVal && newVal instanceof Time) {
58
+ internalValue = newVal;
59
+ value = formatTimeStr(newVal);
60
+ }
61
+ }
62
+
63
+ // Generate 48 half-hour slots
64
+ const slots = Array.from({ length: 48 }, (_, i) => {
65
+ const h = Math.floor(i / 2);
66
+ const m = i % 2 === 0 ? 0 : 30;
67
+ const key = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
68
+ const date = new Date(2000, 0, 1, h, m);
69
+ const label = date.toLocaleTimeString(locale, { hour: 'numeric', minute: '2-digit' });
70
+ return { key, label };
71
+ });
72
+
73
+ function durationLabel(slotKey: string): string {
74
+ if (!referenceTime) return '';
75
+ const [rh, rm] = referenceTime.split(':').map(Number);
76
+ const [sh, sm] = slotKey.split(':').map(Number);
77
+ let diff = (sh * 60 + sm) - (rh * 60 + rm);
78
+ if (diff <= 0) return '';
79
+ const hours = Math.floor(diff / 60);
80
+ const mins = diff % 60;
81
+ if (hours === 0) return `${mins}m`;
82
+ if (mins === 0) return `${hours}h`;
83
+ return `${hours}h ${mins}m`;
84
+ }
85
+
86
+ function selectSlot(key: string) {
87
+ value = key;
88
+ isOpen = false;
89
+ }
90
+
91
+ // Scroll to selected/closest slot when popover opens
92
+ $effect(() => {
93
+ if (isOpen && listEl) {
94
+ tick().then(() => {
95
+ if (!listEl) return;
96
+ const selected = listEl.querySelector('[data-selected]');
97
+ if (selected) {
98
+ selected.scrollIntoView({ block: 'center' });
99
+ } else if (value) {
100
+ const [hStr, mStr] = value.split(':');
101
+ const totalMin = parseInt(hStr, 10) * 60 + parseInt(mStr, 10);
102
+ const closestIdx = Math.min(Math.round(totalMin / 30), 47);
103
+ const el = listEl.children[closestIdx];
104
+ if (el) el.scrollIntoView({ block: 'center' });
105
+ }
106
+ });
107
+ }
108
+ });
109
+ </script>
110
+
111
+ <div class="relative">
112
+ <TimeField.Root
113
+ bind:value={internalValue}
114
+ onValueChange={handleValueChange}
115
+ granularity="minute"
116
+ {locale}
117
+ {required}
118
+ >
119
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
120
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
121
+ <div
122
+ class="border-base-300 bg-base-100 text-base-900 focus-within:border-accent-500 dark:border-base-700 dark:bg-base-800 dark:text-base-100 dark:focus-within:border-accent-400 flex shrink-0 cursor-pointer items-center whitespace-nowrap rounded-xl border px-2.5 py-1.5 text-sm min-w-[7.5rem] transition-colors"
123
+ onfocusin={() => (isOpen = true)}
124
+ >
125
+ <TimeField.Input>
126
+ {#snippet children({ segments })}
127
+ {#each segments as segment, i (segment.part + i)}
128
+ {#if segment.part === 'literal'}
129
+ <span class="text-base-400 dark:text-base-500">{segment.value}</span>
130
+ {:else}
131
+ <TimeField.Segment
132
+ part={segment.part}
133
+ class="hover:bg-base-200 focus:bg-base-200 dark:hover:bg-base-700 dark:focus:bg-base-700 rounded px-0.5 focus:outline-none"
134
+ >
135
+ {segment.value}
136
+ </TimeField.Segment>
137
+ {/if}
138
+ {/each}
139
+ {/snippet}
140
+ </TimeField.Input>
141
+
142
+ <svg
143
+ xmlns="http://www.w3.org/2000/svg"
144
+ fill="none"
145
+ viewBox="0 0 24 24"
146
+ stroke-width="1.5"
147
+ stroke="currentColor"
148
+ class="text-base-400 dark:text-base-500 ml-auto size-4 pl-0.5"
149
+ >
150
+ <path
151
+ stroke-linecap="round"
152
+ stroke-linejoin="round"
153
+ d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
154
+ />
155
+ </svg>
156
+ </div>
157
+ </TimeField.Root>
158
+
159
+ {#if isOpen}
160
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
161
+ <div
162
+ class="fixed inset-0 z-40"
163
+ onclick={() => (isOpen = false)}
164
+ onkeydown={(e) => { if (e.key === 'Escape') isOpen = false; }}
165
+ ></div>
166
+ <div
167
+ bind:this={listEl}
168
+ class="border-base-200 bg-base-50 dark:border-base-700 dark:bg-base-900 absolute left-0 z-50 mt-2 max-h-60 overflow-y-auto rounded-2xl border p-2 shadow-lg"
169
+ >
170
+ {#each slots as slot (slot.key)}
171
+ <button
172
+ type="button"
173
+ class="w-full rounded-lg px-4 py-1.5 text-left text-sm whitespace-nowrap transition-colors
174
+ {value === slot.key
175
+ ? 'bg-accent-100 dark:bg-accent-900 font-medium text-accent-900 dark:text-accent-100'
176
+ : 'text-base-700 hover:bg-base-200 dark:text-base-300 dark:hover:bg-base-700'}"
177
+ data-selected={value === slot.key ? '' : undefined}
178
+ onclick={() => selectSlot(slot.key)}
179
+ >
180
+ {slot.label}
181
+ {#if durationLabel(slot.key)}
182
+ <span class="ml-2 opacity-50">{durationLabel(slot.key)}</span>
183
+ {/if}
184
+ </button>
185
+ {/each}
186
+ </div>
187
+ {/if}
188
+ </div>
@@ -0,0 +1,9 @@
1
+ type $$ComponentProps = {
2
+ value: string;
3
+ required?: boolean;
4
+ locale?: string;
5
+ referenceTime?: string;
6
+ };
7
+ declare const TimePicker: import("svelte").Component<$$ComponentProps, {}, "value">;
8
+ type TimePicker = ReturnType<typeof TimePicker>;
9
+ export default TimePicker;
@@ -0,0 +1,132 @@
1
+ <script lang="ts">
2
+ // @ts-nocheck
3
+ import { tick } from 'svelte';
4
+
5
+ let {
6
+ value = $bindable('')
7
+ }: {
8
+ value: string;
9
+ } = $props();
10
+
11
+ let isOpen = $state(false);
12
+ let search = $state('');
13
+ let listEl: HTMLDivElement | undefined = $state(undefined);
14
+ let searchEl: HTMLInputElement | undefined = $state(undefined);
15
+
16
+ // Get all IANA timezones
17
+ const allTimezones = Intl.supportedValuesOf('timeZone');
18
+
19
+ function getOffset(tz: string): string {
20
+ try {
21
+ const fmt = new Intl.DateTimeFormat('en', {
22
+ timeZone: tz,
23
+ timeZoneName: 'shortOffset'
24
+ });
25
+ const parts = fmt.formatToParts(new Date());
26
+ const tzPart = parts.find((p) => p.type === 'timeZoneName');
27
+ return tzPart?.value ?? '';
28
+ } catch {
29
+ return '';
30
+ }
31
+ }
32
+
33
+ function getCityName(tz: string): string {
34
+ const parts = tz.split('/');
35
+ return (parts[parts.length - 1] || tz).replace(/_/g, ' ');
36
+ }
37
+
38
+ let displayOffset = $derived(getOffset(value));
39
+ let displayCity = $derived(getCityName(value));
40
+
41
+ let filtered = $derived.by(() => {
42
+ if (!search.trim()) return allTimezones;
43
+ const q = search.toLowerCase();
44
+ return allTimezones.filter((tz) => {
45
+ const city = getCityName(tz).toLowerCase();
46
+ const offset = getOffset(tz).toLowerCase();
47
+ return tz.toLowerCase().includes(q) || city.includes(q) || offset.includes(q);
48
+ });
49
+ });
50
+
51
+ function selectTimezone(tz: string) {
52
+ value = tz;
53
+ isOpen = false;
54
+ search = '';
55
+ }
56
+
57
+ $effect(() => {
58
+ if (isOpen) {
59
+ tick().then(() => {
60
+ searchEl?.focus();
61
+ if (listEl) {
62
+ const selected = listEl.querySelector('[data-selected]');
63
+ if (selected) {
64
+ selected.scrollIntoView({ block: 'center' });
65
+ }
66
+ }
67
+ });
68
+ }
69
+ });
70
+ </script>
71
+
72
+ <div class="relative">
73
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
74
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
75
+ <div
76
+ class="border-base-300 bg-base-100 text-base-900 dark:border-base-700 dark:bg-base-800 dark:text-base-100 flex h-full shrink-0 cursor-pointer flex-col items-center justify-center gap-1.5 whitespace-nowrap rounded-xl border px-5 py-2 text-xs transition-colors"
77
+ onclick={() => (isOpen = !isOpen)}
78
+ >
79
+ <svg
80
+ xmlns="http://www.w3.org/2000/svg"
81
+ fill="none"
82
+ viewBox="0 0 24 24"
83
+ stroke-width="1.5"
84
+ stroke="currentColor"
85
+ class="text-base-400 dark:text-base-500 size-4"
86
+ >
87
+ <path
88
+ stroke-linecap="round"
89
+ stroke-linejoin="round"
90
+ d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5a17.92 17.92 0 0 1-8.716-2.247m0 0A8.966 8.966 0 0 1 3 12c0-1.97.633-3.794 1.708-5.282"
91
+ />
92
+ </svg>
93
+ <div class="flex flex-col items-center gap-0.5 leading-tight">
94
+ <span class="text-base-500 dark:text-base-400">{displayOffset}</span>
95
+ <span>{displayCity}</span>
96
+ </div>
97
+ </div>
98
+
99
+ {#if isOpen}
100
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
101
+ <div class="fixed inset-0 z-40" onclick={() => { isOpen = false; search = ''; }} onkeydown={(e) => { if (e.key === 'Escape') { isOpen = false; search = ''; } }}></div>
102
+ <div class="border-base-200 bg-base-50 dark:border-base-700 dark:bg-base-900 absolute right-0 z-50 mt-2 w-64 rounded-2xl border p-2 shadow-lg">
103
+ <input
104
+ bind:this={searchEl}
105
+ bind:value={search}
106
+ type="text"
107
+ placeholder="Search timezone..."
108
+ class="border-base-300 bg-base-100 text-base-900 dark:border-base-700 dark:bg-base-800 dark:text-base-100 mb-2 w-full rounded-lg border px-3 py-1.5 text-sm outline-none focus:border-accent-500 dark:focus:border-accent-400"
109
+ onkeydown={(e) => { if (e.key === 'Escape') { isOpen = false; search = ''; } }}
110
+ />
111
+ <div bind:this={listEl} class="max-h-60 overflow-y-auto">
112
+ {#each filtered as tz (tz)}
113
+ <button
114
+ type="button"
115
+ class="flex w-full items-center justify-between rounded-lg px-3 py-1.5 text-left text-sm transition-colors
116
+ {value === tz
117
+ ? 'bg-accent-100 dark:bg-accent-900 font-medium text-accent-900 dark:text-accent-100'
118
+ : 'text-base-700 hover:bg-base-200 dark:text-base-300 dark:hover:bg-base-700'}"
119
+ data-selected={value === tz ? '' : undefined}
120
+ onclick={() => selectTimezone(tz)}
121
+ >
122
+ <span>{getCityName(tz)}</span>
123
+ <span class="text-base-400 dark:text-base-500 text-xs">{getOffset(tz)}</span>
124
+ </button>
125
+ {/each}
126
+ {#if filtered.length === 0}
127
+ <p class="text-base-400 dark:text-base-500 px-3 py-2 text-sm">No results</p>
128
+ {/if}
129
+ </div>
130
+ </div>
131
+ {/if}
132
+ </div>
@@ -0,0 +1,6 @@
1
+ type $$ComponentProps = {
2
+ value: string;
3
+ };
4
+ declare const TimezonePicker: import("svelte").Component<$$ComponentProps, {}, "value">;
5
+ type TimezonePicker = ReturnType<typeof TimezonePicker>;
6
+ export default TimezonePicker;
@@ -0,0 +1,137 @@
1
+ <script lang="ts" module>
2
+ export type VodPlayerApi = {
3
+ seek: (time: number) => void;
4
+ };
5
+ </script>
6
+
7
+ <script lang="ts">
8
+ import { onMount } from 'svelte';
9
+ import 'plyr/dist/plyr.css';
10
+ import type HlsType from 'hls.js';
11
+ import type PlyrType from 'plyr';
12
+
13
+ let {
14
+ playlistUrl,
15
+ title,
16
+ subtitlesUrl,
17
+ currentTime = $bindable(0),
18
+ api = $bindable(undefined)
19
+ }: {
20
+ playlistUrl: string;
21
+ title: string;
22
+ subtitlesUrl?: string;
23
+ currentTime?: number;
24
+ api?: VodPlayerApi;
25
+ } = $props();
26
+
27
+ let videoEl: HTMLVideoElement | undefined = $state();
28
+ let error = $state(false);
29
+
30
+ let hls: HlsType | null = null;
31
+ let plyr: PlyrType | null = null;
32
+
33
+ api = {
34
+ seek(time: number) {
35
+ if (plyr) {
36
+ plyr.currentTime = time;
37
+ plyr.play();
38
+ }
39
+ }
40
+ };
41
+
42
+ onMount(() => {
43
+ init();
44
+ return () => {
45
+ hls?.destroy();
46
+ plyr?.destroy();
47
+ };
48
+ });
49
+
50
+ async function init() {
51
+ if (!videoEl) return;
52
+
53
+ try {
54
+ const [{ default: Plyr }, { default: Hls }] = await Promise.all([
55
+ import('plyr'),
56
+ import('hls.js')
57
+ ]);
58
+
59
+ if (Hls.isSupported()) {
60
+ hls = new Hls({ autoStartLoad: false });
61
+ hls.loadSource(playlistUrl);
62
+ hls.attachMedia(videoEl);
63
+ hls.on(Hls.Events.ERROR, (_event, data) => {
64
+ if (data.fatal) {
65
+ if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
66
+ hls?.startLoad();
67
+ } else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
68
+ hls?.recoverMediaError();
69
+ } else {
70
+ error = true;
71
+ }
72
+ }
73
+ });
74
+ } else if (videoEl.canPlayType('application/vnd.apple.mpegurl')) {
75
+ videoEl.src = playlistUrl;
76
+ } else {
77
+ error = true;
78
+ return;
79
+ }
80
+
81
+ plyr = new Plyr(videoEl, {
82
+ controls: ['play-large', 'play', 'progress', 'current-time', 'mute', 'volume', 'captions', 'settings', 'fullscreen'],
83
+ settings: ['captions', 'speed'],
84
+ speed: { selected: 1, options: [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2] },
85
+ captions: { active: !!subtitlesUrl, language: 'en', update: true },
86
+ ratio: '16:9'
87
+ });
88
+
89
+ plyr.on('play', () => {
90
+ hls?.startLoad();
91
+ });
92
+
93
+ plyr.on('timeupdate', () => {
94
+ currentTime = plyr?.currentTime ?? 0;
95
+ });
96
+
97
+ // Add track after Plyr init and force captions on
98
+ if (subtitlesUrl) {
99
+ const media = videoEl;
100
+ // Remove any existing tracks first
101
+ media.querySelectorAll('track').forEach((t) => t.remove());
102
+ // Add fresh track
103
+ const track = document.createElement('track');
104
+ track.kind = 'captions';
105
+ track.label = 'English';
106
+ track.srclang = 'en';
107
+ track.src = subtitlesUrl;
108
+ track.default = true;
109
+ media.appendChild(track);
110
+ // Wait for track to load, then activate
111
+ track.addEventListener('load', () => {
112
+ plyr!.toggleCaptions(true);
113
+ });
114
+ // Also try toggling after a short delay as fallback
115
+ setTimeout(() => plyr?.toggleCaptions(true), 500);
116
+ }
117
+ } catch {
118
+ error = true;
119
+ }
120
+ }
121
+ </script>
122
+
123
+ {#if error}
124
+ <div class="bg-base-100 dark:bg-base-900 border-base-200 dark:border-base-800 flex aspect-video w-full items-center justify-center rounded-xl border">
125
+ <p class="text-base-500 dark:text-base-400 text-sm">Failed to load video</p>
126
+ </div>
127
+ {:else}
128
+ <div class="border-base-300 dark:border-base-400/40 aspect-video w-full max-w-full overflow-hidden rounded-xl border">
129
+ <video bind:this={videoEl} class="h-full w-full" aria-label={title} crossorigin="anonymous"></video>
130
+ </div>
131
+ {/if}
132
+
133
+ <style>
134
+ * {
135
+ --plyr-color-main: var(--color-accent-500);
136
+ }
137
+ </style>
@@ -0,0 +1,14 @@
1
+ export type VodPlayerApi = {
2
+ seek: (time: number) => void;
3
+ };
4
+ import 'plyr/dist/plyr.css';
5
+ type $$ComponentProps = {
6
+ playlistUrl: string;
7
+ title: string;
8
+ subtitlesUrl?: string;
9
+ currentTime?: number;
10
+ api?: VodPlayerApi;
11
+ };
12
+ declare const VodPlayer: import("svelte").Component<$$ComponentProps, {}, "currentTime" | "api">;
13
+ type VodPlayer = ReturnType<typeof VodPlayer>;
14
+ export default VodPlayer;