@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.
- package/dist/DatePicker.svelte +231 -0
- package/dist/DatePicker.svelte.d.ts +11 -0
- package/dist/DateTimePicker.svelte +101 -0
- package/dist/DateTimePicker.svelte.d.ts +9 -0
- package/dist/EventAttendees.svelte +203 -0
- package/dist/EventAttendees.svelte.d.ts +13 -0
- package/dist/EventCard.svelte +131 -0
- package/dist/EventCard.svelte.d.ts +8 -0
- package/dist/EventComments.svelte +99 -0
- package/dist/EventComments.svelte.d.ts +6 -0
- package/dist/EventEditor.svelte +589 -0
- package/dist/EventEditor.svelte.d.ts +20 -0
- package/dist/EventRsvp.svelte +237 -0
- package/dist/EventRsvp.svelte.d.ts +17 -0
- package/dist/EventView.svelte +433 -0
- package/dist/EventView.svelte.d.ts +16 -0
- package/dist/ImageDropper.svelte +66 -0
- package/dist/ImageDropper.svelte.d.ts +7 -0
- package/dist/Map.svelte +27 -0
- package/dist/Map.svelte.d.ts +8 -0
- package/dist/PostToBlueskyModal.svelte +244 -0
- package/dist/PostToBlueskyModal.svelte.d.ts +22 -0
- package/dist/ShareModal.svelte +160 -0
- package/dist/ShareModal.svelte.d.ts +23 -0
- package/dist/ThemeApply.svelte +50 -0
- package/dist/ThemeApply.svelte.d.ts +7 -0
- package/dist/ThemeBackground.svelte +33 -0
- package/dist/ThemeBackground.svelte.d.ts +7 -0
- package/dist/ThemePicker.svelte +102 -0
- package/dist/ThemePicker.svelte.d.ts +7 -0
- package/dist/ThumbnailPresets.svelte +68 -0
- package/dist/ThumbnailPresets.svelte.d.ts +11 -0
- package/dist/TimePicker.svelte +188 -0
- package/dist/TimePicker.svelte.d.ts +9 -0
- package/dist/TimezonePicker.svelte +132 -0
- package/dist/TimezonePicker.svelte.d.ts +6 -0
- package/dist/VodPlayer.svelte +137 -0
- package/dist/VodPlayer.svelte.d.ts +14 -0
- package/dist/VodTranscript.svelte +72 -0
- package/dist/VodTranscript.svelte.d.ts +8 -0
- package/dist/atproto-helpers.d.ts +21 -0
- package/dist/atproto-helpers.js +61 -0
- package/dist/cal/helper.d.ts +1 -0
- package/dist/cal/helper.js +20 -0
- package/dist/cal/ical.d.ts +22 -0
- package/dist/cal/ical.js +188 -0
- package/dist/cal/sanitize.d.ts +3 -0
- package/dist/cal/sanitize.js +25 -0
- package/dist/contrail.d.ts +54 -0
- package/dist/contrail.js +22 -0
- package/dist/date-format.d.ts +22 -0
- package/dist/date-format.js +43 -0
- package/dist/editor/LinksSection.svelte +144 -0
- package/dist/editor/LinksSection.svelte.d.ts +10 -0
- package/dist/editor/LocationSection.svelte +215 -0
- package/dist/editor/LocationSection.svelte.d.ts +8 -0
- package/dist/editor/RecurringModal.svelte +270 -0
- package/dist/editor/RecurringModal.svelte.d.ts +30 -0
- package/dist/editor/ThemeSection.svelte +39 -0
- package/dist/editor/ThemeSection.svelte.d.ts +7 -0
- package/dist/editor/ThumbnailSection.svelte +219 -0
- package/dist/editor/ThumbnailSection.svelte.d.ts +13 -0
- package/dist/editor/adapter.d.ts +98 -0
- package/dist/editor/adapter.js +9 -0
- package/dist/editor/save.d.ts +42 -0
- package/dist/editor/save.js +154 -0
- package/dist/editor/types.d.ts +39 -0
- package/dist/editor/types.js +9 -0
- package/dist/event-types.d.ts +70 -0
- package/dist/event-types.js +11 -0
- package/dist/event-view/AddToCalendarButton.svelte +42 -0
- package/dist/event-view/AddToCalendarButton.svelte.d.ts +9 -0
- package/dist/event-view/EventBadges.svelte +20 -0
- package/dist/event-view/EventBadges.svelte.d.ts +7 -0
- package/dist/event-view/EventDateBlock.svelte +43 -0
- package/dist/event-view/EventDateBlock.svelte.d.ts +7 -0
- package/dist/event-view/EventHostedBy.svelte +63 -0
- package/dist/event-view/EventHostedBy.svelte.d.ts +16 -0
- package/dist/event-view/EventLinksList.svelte +37 -0
- package/dist/event-view/EventLinksList.svelte.d.ts +9 -0
- package/dist/event-view/EventLocationBlock.svelte +48 -0
- package/dist/event-view/EventLocationBlock.svelte.d.ts +7 -0
- package/dist/event-view/EventLocationMap.svelte +72 -0
- package/dist/event-view/EventLocationMap.svelte.d.ts +8 -0
- package/dist/event-view/ExternalRsvpNotice.svelte +44 -0
- package/dist/event-view/ExternalRsvpNotice.svelte.d.ts +6 -0
- package/dist/event-view/InviteShareFlow.svelte +177 -0
- package/dist/event-view/InviteShareFlow.svelte.d.ts +15 -0
- package/dist/event-view/StreamPlacePlayer.svelte +222 -0
- package/dist/event-view/StreamPlacePlayer.svelte.d.ts +8 -0
- package/dist/event-view/format.d.ts +26 -0
- package/dist/event-view/format.js +145 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +18 -0
- package/dist/profile-url.d.ts +1 -0
- package/dist/profile-url.js +7 -0
- package/dist/theme.d.ts +9 -0
- package/dist/theme.js +22 -0
- package/dist/themes/Blobs.svelte +35 -0
- package/dist/themes/Blobs.svelte.d.ts +26 -0
- package/dist/themes/Butterflies.svelte +185 -0
- package/dist/themes/Butterflies.svelte.d.ts +3 -0
- package/dist/themes/Fireflies.svelte +134 -0
- package/dist/themes/Fireflies.svelte.d.ts +3 -0
- package/dist/themes/Kaleidoscope.svelte +177 -0
- package/dist/themes/Kaleidoscope.svelte.d.ts +3 -0
- package/dist/themes/Matrix.svelte +150 -0
- package/dist/themes/Matrix.svelte.d.ts +3 -0
- package/dist/themes/Stars.svelte +98 -0
- package/dist/themes/Stars.svelte.d.ts +3 -0
- package/dist/thumbnails/designs.d.ts +18 -0
- package/dist/thumbnails/designs.js +316 -0
- 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,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;
|