@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,177 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Button, Checkbox, Input, Label, Modal } from '@foxui/core';
|
|
3
|
+
import DateTimePicker from '../DateTimePicker.svelte';
|
|
4
|
+
import ShareModal from '../ShareModal.svelte';
|
|
5
|
+
import { datetimeLocalToISO } from '../date-format.js';
|
|
6
|
+
import type { HostProfile } from '../contrail.js';
|
|
7
|
+
import type { EditorAdapter, EditorViewer } from '../editor/adapter.js';
|
|
8
|
+
|
|
9
|
+
let {
|
|
10
|
+
spaceUri,
|
|
11
|
+
spaceKey,
|
|
12
|
+
did,
|
|
13
|
+
rkey,
|
|
14
|
+
eventName,
|
|
15
|
+
hostProfile,
|
|
16
|
+
adapter,
|
|
17
|
+
viewer
|
|
18
|
+
}: {
|
|
19
|
+
spaceUri: string;
|
|
20
|
+
spaceKey: string;
|
|
21
|
+
did: string;
|
|
22
|
+
rkey: string;
|
|
23
|
+
eventName: string;
|
|
24
|
+
hostProfile: HostProfile | null | undefined;
|
|
25
|
+
adapter: EditorAdapter;
|
|
26
|
+
viewer: EditorViewer;
|
|
27
|
+
} = $props();
|
|
28
|
+
|
|
29
|
+
let inviteUrl: string | null = $state(null);
|
|
30
|
+
let inviteBusy = $state(false);
|
|
31
|
+
let inviteError: string | null = $state(null);
|
|
32
|
+
let showInviteModal = $state(false);
|
|
33
|
+
|
|
34
|
+
// Invite-options dialog (shown before the share modal) — owner picks
|
|
35
|
+
// whether to allow anonymous reads, max uses, and expiry.
|
|
36
|
+
let showInviteForm = $state(false);
|
|
37
|
+
let inviteAllowAnonRead = $state(true);
|
|
38
|
+
let inviteMaxUsesText = $state('');
|
|
39
|
+
let inviteHasExpiry = $state(false);
|
|
40
|
+
let inviteExpiresAt = $state('');
|
|
41
|
+
const inviteTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
42
|
+
|
|
43
|
+
function openInviteForm() {
|
|
44
|
+
inviteError = null;
|
|
45
|
+
inviteAllowAnonRead = true;
|
|
46
|
+
inviteMaxUsesText = '';
|
|
47
|
+
inviteHasExpiry = false;
|
|
48
|
+
showInviteForm = true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function submitInviteForm() {
|
|
52
|
+
if (inviteBusy) return;
|
|
53
|
+
inviteBusy = true;
|
|
54
|
+
inviteError = null;
|
|
55
|
+
try {
|
|
56
|
+
let maxUses: number | undefined;
|
|
57
|
+
if (inviteMaxUsesText.trim()) {
|
|
58
|
+
const n = Number(inviteMaxUsesText);
|
|
59
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
60
|
+
throw new Error('Max uses must be a positive integer.');
|
|
61
|
+
}
|
|
62
|
+
maxUses = n;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let expiresAt: number | undefined;
|
|
66
|
+
if (inviteHasExpiry && inviteExpiresAt.trim()) {
|
|
67
|
+
const iso = datetimeLocalToISO(inviteExpiresAt, inviteTimezone);
|
|
68
|
+
const ts = new Date(iso).getTime();
|
|
69
|
+
if (!Number.isFinite(ts)) throw new Error('Invalid expiry date.');
|
|
70
|
+
if (ts <= Date.now()) throw new Error('Expiry must be in the future.');
|
|
71
|
+
expiresAt = ts;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!adapter.createSpaceInvite) {
|
|
75
|
+
throw new Error('createSpaceInvite not supported by this adapter');
|
|
76
|
+
}
|
|
77
|
+
const result = await adapter.createSpaceInvite({
|
|
78
|
+
spaceUri,
|
|
79
|
+
kind: inviteAllowAnonRead ? 'read-join' : 'join',
|
|
80
|
+
...(maxUses != null ? { maxUses } : {}),
|
|
81
|
+
...(expiresAt != null ? { expiresAt } : {})
|
|
82
|
+
});
|
|
83
|
+
inviteUrl = `${window.location.origin}/p/${hostProfile?.handle || did}/e/${rkey}/s/${spaceKey}?invite=${result.token}`;
|
|
84
|
+
showInviteForm = false;
|
|
85
|
+
showInviteModal = true;
|
|
86
|
+
} catch (e) {
|
|
87
|
+
inviteError = e instanceof Error ? e.message : String(e);
|
|
88
|
+
} finally {
|
|
89
|
+
inviteBusy = false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
</script>
|
|
93
|
+
|
|
94
|
+
<Button variant="secondary" class="mt-3 w-full" onclick={openInviteForm}>
|
|
95
|
+
Share invite link
|
|
96
|
+
</Button>
|
|
97
|
+
|
|
98
|
+
<Modal bind:open={showInviteForm} interactOutsideBehavior={inviteBusy ? 'ignore' : 'close'}>
|
|
99
|
+
<h2 class="mb-4 text-lg font-semibold">Create invite link</h2>
|
|
100
|
+
|
|
101
|
+
<form
|
|
102
|
+
class="space-y-4"
|
|
103
|
+
onsubmit={(e) => {
|
|
104
|
+
e.preventDefault();
|
|
105
|
+
submitInviteForm();
|
|
106
|
+
}}
|
|
107
|
+
>
|
|
108
|
+
<div class="flex items-start gap-2">
|
|
109
|
+
<Checkbox id="invite-allow-anon" bind:checked={inviteAllowAnonRead} disabled={inviteBusy} />
|
|
110
|
+
<div>
|
|
111
|
+
<Label for="invite-allow-anon">Allow viewing event without being logged in</Label>
|
|
112
|
+
<p class="text-base-500 dark:text-base-400 mt-0.5 text-xs">
|
|
113
|
+
Anyone with this link can read the event details. Signed-in users can still join with the
|
|
114
|
+
same link.
|
|
115
|
+
</p>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
<div>
|
|
120
|
+
<Label for="invite-max-uses">Max uses</Label>
|
|
121
|
+
<Input
|
|
122
|
+
id="invite-max-uses"
|
|
123
|
+
type="number"
|
|
124
|
+
min="1"
|
|
125
|
+
bind:value={inviteMaxUsesText}
|
|
126
|
+
placeholder="Unlimited"
|
|
127
|
+
disabled={inviteBusy}
|
|
128
|
+
/>
|
|
129
|
+
<p class="text-base-500 dark:text-base-400 mt-1 text-xs">
|
|
130
|
+
Caps how many people can join — anonymous reads are always unlimited. Leave empty for no
|
|
131
|
+
limit.
|
|
132
|
+
</p>
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
<div>
|
|
136
|
+
<div class="mb-1 flex items-center gap-2">
|
|
137
|
+
<Checkbox id="invite-has-expiry" bind:checked={inviteHasExpiry} disabled={inviteBusy} />
|
|
138
|
+
<Label for="invite-has-expiry">Set an expiry</Label>
|
|
139
|
+
</div>
|
|
140
|
+
{#if inviteHasExpiry}
|
|
141
|
+
<DateTimePicker bind:value={inviteExpiresAt} />
|
|
142
|
+
{:else}
|
|
143
|
+
<p class="text-base-500 dark:text-base-400 text-xs">Link never expires.</p>
|
|
144
|
+
{/if}
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
{#if inviteError}
|
|
148
|
+
<p class="text-sm text-red-600 dark:text-red-400">{inviteError}</p>
|
|
149
|
+
{/if}
|
|
150
|
+
|
|
151
|
+
<div class="flex justify-end gap-2 pt-2">
|
|
152
|
+
<Button
|
|
153
|
+
type="button"
|
|
154
|
+
variant="secondary"
|
|
155
|
+
onclick={() => (showInviteForm = false)}
|
|
156
|
+
disabled={inviteBusy}
|
|
157
|
+
>
|
|
158
|
+
Cancel
|
|
159
|
+
</Button>
|
|
160
|
+
<Button type="submit" disabled={inviteBusy}>
|
|
161
|
+
{inviteBusy ? 'Creating…' : 'Create invite'}
|
|
162
|
+
</Button>
|
|
163
|
+
</div>
|
|
164
|
+
</form>
|
|
165
|
+
</Modal>
|
|
166
|
+
|
|
167
|
+
{#if inviteUrl}
|
|
168
|
+
<ShareModal
|
|
169
|
+
bind:open={showInviteModal}
|
|
170
|
+
url={inviteUrl}
|
|
171
|
+
title="Invite link"
|
|
172
|
+
shareText={`You're invited to "${eventName}".\n\n${inviteUrl}`}
|
|
173
|
+
{eventName}
|
|
174
|
+
{adapter}
|
|
175
|
+
{viewer}
|
|
176
|
+
/>
|
|
177
|
+
{/if}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { HostProfile } from '../contrail.js';
|
|
2
|
+
import type { EditorAdapter, EditorViewer } from '../editor/adapter.js';
|
|
3
|
+
type $$ComponentProps = {
|
|
4
|
+
spaceUri: string;
|
|
5
|
+
spaceKey: string;
|
|
6
|
+
did: string;
|
|
7
|
+
rkey: string;
|
|
8
|
+
eventName: string;
|
|
9
|
+
hostProfile: HostProfile | null | undefined;
|
|
10
|
+
adapter: EditorAdapter;
|
|
11
|
+
viewer: EditorViewer;
|
|
12
|
+
};
|
|
13
|
+
declare const InviteShareFlow: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
14
|
+
type InviteShareFlow = ReturnType<typeof InviteShareFlow>;
|
|
15
|
+
export default InviteShareFlow;
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount } from 'svelte';
|
|
3
|
+
import 'plyr/dist/plyr.css';
|
|
4
|
+
import type PlyrType from 'plyr';
|
|
5
|
+
|
|
6
|
+
let {
|
|
7
|
+
handle,
|
|
8
|
+
title
|
|
9
|
+
}: {
|
|
10
|
+
handle: string;
|
|
11
|
+
title: string;
|
|
12
|
+
} = $props();
|
|
13
|
+
|
|
14
|
+
let videoEl: HTMLVideoElement | undefined = $state();
|
|
15
|
+
let overlayEl: HTMLAnchorElement | undefined = $state();
|
|
16
|
+
let error = $state(false);
|
|
17
|
+
let controlsVisible = $state(true);
|
|
18
|
+
|
|
19
|
+
let pc: RTCPeerConnection | null = null;
|
|
20
|
+
let plyr: PlyrType | null = null;
|
|
21
|
+
|
|
22
|
+
const posterUrl = $derived(
|
|
23
|
+
`https://stream.place/api/playback/${encodeURIComponent(handle)}/stream.jpg`
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
onMount(() => {
|
|
27
|
+
init();
|
|
28
|
+
return () => {
|
|
29
|
+
if (pc) {
|
|
30
|
+
pc.close();
|
|
31
|
+
pc = null;
|
|
32
|
+
}
|
|
33
|
+
if (videoEl) {
|
|
34
|
+
videoEl.srcObject = null;
|
|
35
|
+
}
|
|
36
|
+
plyr?.destroy();
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
function waitForIceGathering(peer: RTCPeerConnection, timeoutMs = 2000): Promise<void> {
|
|
41
|
+
return new Promise((resolve) => {
|
|
42
|
+
if (peer.iceGatheringState === 'complete') {
|
|
43
|
+
resolve();
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let done = false;
|
|
48
|
+
const finish = () => {
|
|
49
|
+
if (done) return;
|
|
50
|
+
done = true;
|
|
51
|
+
peer.removeEventListener('icegatheringstatechange', onStateChange);
|
|
52
|
+
clearTimeout(timer);
|
|
53
|
+
resolve();
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const onStateChange = () => {
|
|
57
|
+
if (peer.iceGatheringState === 'complete') {
|
|
58
|
+
finish();
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
peer.addEventListener('icegatheringstatechange', onStateChange);
|
|
63
|
+
const timer = setTimeout(finish, timeoutMs);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function init() {
|
|
68
|
+
if (!videoEl) return;
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
pc = new RTCPeerConnection({
|
|
72
|
+
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
|
|
73
|
+
bundlePolicy: 'max-bundle'
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
pc.addTransceiver('video', { direction: 'recvonly' });
|
|
77
|
+
pc.addTransceiver('audio', { direction: 'recvonly' });
|
|
78
|
+
|
|
79
|
+
pc.addEventListener('track', (event) => {
|
|
80
|
+
if (!videoEl) return;
|
|
81
|
+
if (event.streams && event.streams[0]) {
|
|
82
|
+
videoEl.srcObject = event.streams[0];
|
|
83
|
+
} else {
|
|
84
|
+
let stream = videoEl.srcObject as MediaStream | null;
|
|
85
|
+
if (!stream) {
|
|
86
|
+
stream = new MediaStream();
|
|
87
|
+
videoEl.srcObject = stream;
|
|
88
|
+
}
|
|
89
|
+
stream.addTrack(event.track);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const offer = await pc.createOffer();
|
|
94
|
+
await pc.setLocalDescription(offer);
|
|
95
|
+
await waitForIceGathering(pc, 2000);
|
|
96
|
+
|
|
97
|
+
const sdp = pc.localDescription?.sdp;
|
|
98
|
+
if (!sdp) {
|
|
99
|
+
error = true;
|
|
100
|
+
pc.close();
|
|
101
|
+
pc = null;
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const response = await fetch(
|
|
106
|
+
`https://stream.place/api/playback/${encodeURIComponent(handle)}/webrtc?rendition=source`,
|
|
107
|
+
{
|
|
108
|
+
method: 'POST',
|
|
109
|
+
headers: { 'Content-Type': 'application/sdp' },
|
|
110
|
+
body: sdp
|
|
111
|
+
}
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
if (!response.ok) {
|
|
115
|
+
error = true;
|
|
116
|
+
pc.close();
|
|
117
|
+
pc = null;
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const answerSdp = await response.text();
|
|
122
|
+
await pc.setRemoteDescription({ type: 'answer', sdp: answerSdp });
|
|
123
|
+
|
|
124
|
+
const { default: Plyr } = await import('plyr');
|
|
125
|
+
|
|
126
|
+
plyr = new Plyr(videoEl, {
|
|
127
|
+
controls: ['play', 'mute', 'volume', 'fullscreen'],
|
|
128
|
+
settings: [],
|
|
129
|
+
ratio: '16:9'
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
plyr.on('controlshidden', () => {
|
|
133
|
+
controlsVisible = false;
|
|
134
|
+
});
|
|
135
|
+
plyr.on('controlsshown', () => {
|
|
136
|
+
controlsVisible = true;
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Move the overlay link inside Plyr's container so mousemove over the
|
|
140
|
+
// button bubbles into Plyr's activity tracker — otherwise hovering it
|
|
141
|
+
// hides the controls, which hides the button, which reactivates Plyr
|
|
142
|
+
// (a flicker loop).
|
|
143
|
+
const plyrEl = videoEl.closest('.plyr');
|
|
144
|
+
if (plyrEl && overlayEl) plyrEl.appendChild(overlayEl);
|
|
145
|
+
} catch {
|
|
146
|
+
error = true;
|
|
147
|
+
if (pc) {
|
|
148
|
+
pc.close();
|
|
149
|
+
pc = null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
</script>
|
|
154
|
+
|
|
155
|
+
{#snippet watchOnStreamPlace(hidden = false)}
|
|
156
|
+
<a
|
|
157
|
+
bind:this={overlayEl}
|
|
158
|
+
href="https://stream.place/{handle}"
|
|
159
|
+
target="_blank"
|
|
160
|
+
rel="noopener noreferrer"
|
|
161
|
+
onclick={() => plyr?.pause()}
|
|
162
|
+
class="absolute top-3 right-3 z-10 inline-flex items-center gap-1.5 rounded-full bg-black/55 px-2.5 py-1 text-xs font-medium text-white backdrop-blur-sm transition-opacity duration-200 hover:bg-black/75 {hidden
|
|
163
|
+
? 'pointer-events-none opacity-0'
|
|
164
|
+
: 'opacity-100'}"
|
|
165
|
+
>
|
|
166
|
+
Watch on stream.place
|
|
167
|
+
<svg
|
|
168
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
169
|
+
viewBox="0 0 20 20"
|
|
170
|
+
fill="currentColor"
|
|
171
|
+
class="size-3"
|
|
172
|
+
aria-hidden="true"
|
|
173
|
+
>
|
|
174
|
+
<path
|
|
175
|
+
fill-rule="evenodd"
|
|
176
|
+
d="M4.25 5.5a.75.75 0 0 0-.75.75v8.5c0 .414.336.75.75.75h8.5a.75.75 0 0 0 .75-.75v-4a.75.75 0 0 1 1.5 0v4A2.25 2.25 0 0 1 12.75 17h-8.5A2.25 2.25 0 0 1 2 14.75v-8.5A2.25 2.25 0 0 1 4.25 4h5a.75.75 0 0 1 0 1.5h-5Z"
|
|
177
|
+
clip-rule="evenodd"
|
|
178
|
+
/>
|
|
179
|
+
<path
|
|
180
|
+
fill-rule="evenodd"
|
|
181
|
+
d="M6.194 12.753a.75.75 0 0 0 1.06.053L16.5 4.44v2.81a.75.75 0 0 0 1.5 0v-4.5a.75.75 0 0 0-.75-.75h-4.5a.75.75 0 0 0 0 1.5h2.553l-9.056 8.194a.75.75 0 0 0-.053 1.06Z"
|
|
182
|
+
clip-rule="evenodd"
|
|
183
|
+
/>
|
|
184
|
+
</svg>
|
|
185
|
+
</a>
|
|
186
|
+
{/snippet}
|
|
187
|
+
|
|
188
|
+
{#if error}
|
|
189
|
+
<div
|
|
190
|
+
class="bg-base-100 dark:bg-base-900 border-base-200 dark:border-base-800 relative flex aspect-video w-full items-center justify-center overflow-hidden rounded-xl border"
|
|
191
|
+
>
|
|
192
|
+
<div
|
|
193
|
+
class="absolute inset-0 bg-cover bg-center"
|
|
194
|
+
style="background-image: url({posterUrl});"
|
|
195
|
+
></div>
|
|
196
|
+
<div class="absolute inset-0 bg-black/60"></div>
|
|
197
|
+
{@render watchOnStreamPlace()}
|
|
198
|
+
<p class="text-base-100 relative text-sm">Stream is offline</p>
|
|
199
|
+
</div>
|
|
200
|
+
{:else}
|
|
201
|
+
<div
|
|
202
|
+
class="border-base-300 dark:border-base-400/40 relative aspect-video w-full max-w-full overflow-hidden rounded-xl border"
|
|
203
|
+
>
|
|
204
|
+
{@render watchOnStreamPlace(!controlsVisible)}
|
|
205
|
+
<video
|
|
206
|
+
bind:this={videoEl}
|
|
207
|
+
class="h-full w-full"
|
|
208
|
+
aria-label={title}
|
|
209
|
+
poster={posterUrl}
|
|
210
|
+
autoplay
|
|
211
|
+
playsinline
|
|
212
|
+
muted
|
|
213
|
+
crossorigin="anonymous"
|
|
214
|
+
></video>
|
|
215
|
+
</div>
|
|
216
|
+
{/if}
|
|
217
|
+
|
|
218
|
+
<style>
|
|
219
|
+
* {
|
|
220
|
+
--plyr-color-main: var(--color-accent-500);
|
|
221
|
+
}
|
|
222
|
+
</style>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import 'plyr/dist/plyr.css';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
handle: string;
|
|
4
|
+
title: string;
|
|
5
|
+
};
|
|
6
|
+
declare const StreamPlacePlayer: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
7
|
+
type StreamPlacePlayer = ReturnType<typeof StreamPlacePlayer>;
|
|
8
|
+
export default StreamPlacePlayer;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { FlatEventRecord } from '../contrail.js';
|
|
2
|
+
export declare function formatMonth(date: Date): string;
|
|
3
|
+
export declare function formatDay(date: Date): number;
|
|
4
|
+
export declare function formatWeekday(date: Date): string;
|
|
5
|
+
export declare function formatFullDate(date: Date): string;
|
|
6
|
+
export declare function formatTime(date: Date): string;
|
|
7
|
+
export declare function getModeLabel(mode: string): string;
|
|
8
|
+
/** Foxui accent-color class — applied alongside `variant="primary"` on a Badge
|
|
9
|
+
* to override its `--accent-*` CSS variables (see @foxui/core/dist/theme.css). */
|
|
10
|
+
export declare function getModeColor(mode: string): string;
|
|
11
|
+
export type LocationData = {
|
|
12
|
+
name?: string;
|
|
13
|
+
shortAddress: string;
|
|
14
|
+
fullAddress: string;
|
|
15
|
+
fullString: string;
|
|
16
|
+
googleMapsUrl: string;
|
|
17
|
+
};
|
|
18
|
+
export declare function getLocationData(locations: FlatEventRecord['locations']): LocationData | null;
|
|
19
|
+
export type GeoLocation = {
|
|
20
|
+
lat: number;
|
|
21
|
+
lng: number;
|
|
22
|
+
googleMapsUrl: string;
|
|
23
|
+
osmUrl: string;
|
|
24
|
+
};
|
|
25
|
+
export declare function resolveGeoLocation(locations: FlatEventRecord['locations'], locationData: LocationData | null): Promise<GeoLocation | null>;
|
|
26
|
+
export declare function buildDescriptionHtml(description: string | undefined, facets: unknown): string | null;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { marked } from 'marked';
|
|
2
|
+
import { sanitize } from '../cal/sanitize.js';
|
|
3
|
+
import { getProfileUrl } from '../profile-url.js';
|
|
4
|
+
export function formatMonth(date) {
|
|
5
|
+
return date.toLocaleDateString('en-US', { month: 'short' }).toUpperCase();
|
|
6
|
+
}
|
|
7
|
+
export function formatDay(date) {
|
|
8
|
+
return date.getDate();
|
|
9
|
+
}
|
|
10
|
+
export function formatWeekday(date) {
|
|
11
|
+
return date.toLocaleDateString('en-US', { weekday: 'long' });
|
|
12
|
+
}
|
|
13
|
+
export function formatFullDate(date) {
|
|
14
|
+
const options = { month: 'long', day: 'numeric' };
|
|
15
|
+
if (date.getFullYear() !== new Date().getFullYear()) {
|
|
16
|
+
options.year = 'numeric';
|
|
17
|
+
}
|
|
18
|
+
return date.toLocaleDateString('en-US', options);
|
|
19
|
+
}
|
|
20
|
+
export function formatTime(date) {
|
|
21
|
+
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
|
22
|
+
}
|
|
23
|
+
export function getModeLabel(mode) {
|
|
24
|
+
if (mode.includes('virtual'))
|
|
25
|
+
return 'Virtual';
|
|
26
|
+
if (mode.includes('hybrid'))
|
|
27
|
+
return 'Hybrid';
|
|
28
|
+
if (mode.includes('inperson'))
|
|
29
|
+
return 'In-Person';
|
|
30
|
+
return 'Event';
|
|
31
|
+
}
|
|
32
|
+
/** Foxui accent-color class — applied alongside `variant="primary"` on a Badge
|
|
33
|
+
* to override its `--accent-*` CSS variables (see @foxui/core/dist/theme.css). */
|
|
34
|
+
export function getModeColor(mode) {
|
|
35
|
+
if (mode.includes('virtual'))
|
|
36
|
+
return 'cyan';
|
|
37
|
+
if (mode.includes('hybrid'))
|
|
38
|
+
return 'purple';
|
|
39
|
+
if (mode.includes('inperson'))
|
|
40
|
+
return 'amber';
|
|
41
|
+
return '';
|
|
42
|
+
}
|
|
43
|
+
export function getLocationData(locations) {
|
|
44
|
+
if (!locations?.length)
|
|
45
|
+
return null;
|
|
46
|
+
const loc = locations.find((v) => v.$type === 'community.lexicon.location.address');
|
|
47
|
+
if (!loc)
|
|
48
|
+
return null;
|
|
49
|
+
const shortParts = [loc.street, loc.locality].filter(Boolean);
|
|
50
|
+
const fullParts = [loc.street, loc.locality, loc.region, loc.country].filter(Boolean);
|
|
51
|
+
if (fullParts.length === 0)
|
|
52
|
+
return null;
|
|
53
|
+
const shortAddress = shortParts.join(', ');
|
|
54
|
+
const fullAddress = fullParts.join(', ');
|
|
55
|
+
const displayName = loc.name || undefined;
|
|
56
|
+
const fullString = displayName ? `${displayName}, ${fullAddress}` : fullAddress;
|
|
57
|
+
const googleMapsUrl = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(fullString)}`;
|
|
58
|
+
return { name: displayName, shortAddress, fullAddress, fullString, googleMapsUrl };
|
|
59
|
+
}
|
|
60
|
+
function geoUrls(lat, lng, osmType, osmId) {
|
|
61
|
+
return {
|
|
62
|
+
googleMapsUrl: `https://www.google.com/maps/search/?api=1&query=${lat},${lng}`,
|
|
63
|
+
osmUrl: osmType && osmId
|
|
64
|
+
? `https://www.openstreetmap.org/${osmType}/${osmId}`
|
|
65
|
+
: `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lng}#map=17/${lat}/${lng}`
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
export async function resolveGeoLocation(locations, locationData) {
|
|
69
|
+
if (!locations?.length)
|
|
70
|
+
return null;
|
|
71
|
+
const geo = locations.find((v) => v.$type === 'community.lexicon.location.geo');
|
|
72
|
+
if (geo?.latitude && geo?.longitude) {
|
|
73
|
+
const lat = parseFloat(geo.latitude);
|
|
74
|
+
const lng = parseFloat(geo.longitude);
|
|
75
|
+
if (!isNaN(lat) && !isNaN(lng))
|
|
76
|
+
return { lat, lng, ...geoUrls(lat, lng) };
|
|
77
|
+
}
|
|
78
|
+
if (!locationData?.fullAddress)
|
|
79
|
+
return null;
|
|
80
|
+
try {
|
|
81
|
+
const r = await fetch(`/api/geocoding?q=${encodeURIComponent(locationData.fullAddress)}`);
|
|
82
|
+
if (!r.ok)
|
|
83
|
+
return null;
|
|
84
|
+
const data = (await r.json());
|
|
85
|
+
if (!data?.lat || !data?.lon)
|
|
86
|
+
return null;
|
|
87
|
+
const lat = parseFloat(data.lat);
|
|
88
|
+
const lng = parseFloat(data.lon);
|
|
89
|
+
if (isNaN(lat) || isNaN(lng))
|
|
90
|
+
return null;
|
|
91
|
+
return { lat, lng, ...geoUrls(lat, lng, data.osm_type, data.osm_id) };
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const renderer = new marked.Renderer();
|
|
98
|
+
renderer.link = ({ href, text }) => `<a target="_blank" rel="noopener noreferrer nofollow" href="${href}" class="text-accent-600 dark:text-accent-400 hover:underline">${text}</a>`;
|
|
99
|
+
function renderDescription(text, facets) {
|
|
100
|
+
let result = text;
|
|
101
|
+
if (facets && facets.length > 0) {
|
|
102
|
+
const encoded = new TextEncoder().encode(text);
|
|
103
|
+
const decoder = new TextDecoder();
|
|
104
|
+
const sorted = [...facets].sort((a, b) => a.index.byteStart - b.index.byteStart);
|
|
105
|
+
const parts = [];
|
|
106
|
+
let cursor = 0;
|
|
107
|
+
for (const facet of sorted) {
|
|
108
|
+
const feature = facet.features?.[0];
|
|
109
|
+
if (!feature)
|
|
110
|
+
continue;
|
|
111
|
+
if (facet.index.byteStart < cursor)
|
|
112
|
+
continue;
|
|
113
|
+
const segmentText = decoder.decode(encoded.slice(facet.index.byteStart, facet.index.byteEnd));
|
|
114
|
+
let mdLink = null;
|
|
115
|
+
switch (feature.$type) {
|
|
116
|
+
case 'app.bsky.richtext.facet#mention': {
|
|
117
|
+
const handle = segmentText.startsWith('@') ? segmentText.slice(1) : segmentText;
|
|
118
|
+
mdLink = `[${segmentText}](${getProfileUrl(handle || feature.did || '')})`;
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
case 'app.bsky.richtext.facet#link':
|
|
122
|
+
mdLink = `[${segmentText}](${feature.uri})`;
|
|
123
|
+
break;
|
|
124
|
+
case 'app.bsky.richtext.facet#tag':
|
|
125
|
+
mdLink = `[${segmentText}](https://bsky.app/hashtag/${feature.tag})`;
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
if (mdLink) {
|
|
129
|
+
parts.push(decoder.decode(encoded.slice(cursor, facet.index.byteStart)));
|
|
130
|
+
parts.push(mdLink);
|
|
131
|
+
cursor = facet.index.byteEnd;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
parts.push(decoder.decode(encoded.slice(cursor)));
|
|
135
|
+
result = parts.join('');
|
|
136
|
+
}
|
|
137
|
+
return marked.parse(result, { renderer });
|
|
138
|
+
}
|
|
139
|
+
export function buildDescriptionHtml(description, facets) {
|
|
140
|
+
if (!description)
|
|
141
|
+
return null;
|
|
142
|
+
return sanitize(renderDescription(description, facets), {
|
|
143
|
+
ADD_ATTR: ['target']
|
|
144
|
+
});
|
|
145
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export { default as EventView } from './EventView.svelte';
|
|
2
|
+
export { default as EventEditor } from './EventEditor.svelte';
|
|
3
|
+
export { default as EventCard } from './EventCard.svelte';
|
|
4
|
+
export { default as EventRsvp } from './EventRsvp.svelte';
|
|
5
|
+
export { default as EventComments } from './EventComments.svelte';
|
|
6
|
+
export { default as EventAttendees } from './EventAttendees.svelte';
|
|
7
|
+
export { default as VodPlayer } from './VodPlayer.svelte';
|
|
8
|
+
export { default as VodTranscript } from './VodTranscript.svelte';
|
|
9
|
+
export type { EditorAdapter, EditorBlobRef, EditorViewer } from './editor/adapter.js';
|
|
10
|
+
export type { EventEditorPrefill } from './editor/types.js';
|
|
11
|
+
export type { FlatEventRecord, HostProfile, AttendeeInfo } from './contrail.js';
|
|
12
|
+
export type { EventData, EventLexiconMain, EventLocationVariant, EventStatus, EventMode as EventLexiconMode } from './event-types.js';
|
|
13
|
+
export type { EventTheme } from './theme.js';
|
|
14
|
+
export { defaultTheme, themeBackgrounds, randomAccentColor, accentColors } from './theme.js';
|
|
15
|
+
export { eventUrl, isEventOngoing, RSVP_GOING, RSVP_INTERESTED } from './contrail.js';
|
|
16
|
+
export { getCDNImageBlobUrl, compressImage } from './atproto-helpers.js';
|
|
17
|
+
export { getProfileUrl } from './profile-url.js';
|
|
18
|
+
export { datetimeLocalToISO, isoToDatetimeLocalInTz, formatInTz, partsInTz } from './date-format.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Components
|
|
2
|
+
export { default as EventView } from './EventView.svelte';
|
|
3
|
+
export { default as EventEditor } from './EventEditor.svelte';
|
|
4
|
+
export { default as EventCard } from './EventCard.svelte';
|
|
5
|
+
export { default as EventRsvp } from './EventRsvp.svelte';
|
|
6
|
+
export { default as EventComments } from './EventComments.svelte';
|
|
7
|
+
export { default as EventAttendees } from './EventAttendees.svelte';
|
|
8
|
+
export { default as VodPlayer } from './VodPlayer.svelte';
|
|
9
|
+
export { default as VodTranscript } from './VodTranscript.svelte';
|
|
10
|
+
// Theme values
|
|
11
|
+
export { defaultTheme, themeBackgrounds, randomAccentColor, accentColors } from './theme.js';
|
|
12
|
+
// Event helpers
|
|
13
|
+
export { eventUrl, isEventOngoing, RSVP_GOING, RSVP_INTERESTED } from './contrail.js';
|
|
14
|
+
// Atproto helpers (browser-safe, no client/session state)
|
|
15
|
+
export { getCDNImageBlobUrl, compressImage } from './atproto-helpers.js';
|
|
16
|
+
export { getProfileUrl } from './profile-url.js';
|
|
17
|
+
// Date/time helpers
|
|
18
|
+
export { datetimeLocalToISO, isoToDatetimeLocalInTz, formatInTz, partsInTz } from './date-format.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function getProfileUrl(handleOrDid: string): string;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export function getProfileUrl(handleOrDid) {
|
|
2
|
+
const lower = handleOrDid.toLowerCase();
|
|
3
|
+
if (lower.endsWith('.blacksky.team') || lower.endsWith('.blacksky.app')) {
|
|
4
|
+
return `https://blacksky.community/profile/${handleOrDid}`;
|
|
5
|
+
}
|
|
6
|
+
return `https://bsky.app/profile/${handleOrDid}`;
|
|
7
|
+
}
|
package/dist/theme.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface EventTheme {
|
|
2
|
+
name: string;
|
|
3
|
+
accentColor: string;
|
|
4
|
+
baseColor: string;
|
|
5
|
+
}
|
|
6
|
+
export declare const accentColors: readonly ["red", "orange", "amber", "yellow", "lime", "green", "emerald", "teal", "cyan", "sky", "blue", "indigo", "violet", "purple", "fuchsia", "pink", "rose"];
|
|
7
|
+
export declare const defaultTheme: EventTheme;
|
|
8
|
+
export declare function randomAccentColor(): string;
|
|
9
|
+
export declare const themeBackgrounds: Record<string, string>;
|
package/dist/theme.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export const accentColors = [
|
|
2
|
+
'red', 'orange', 'amber', 'yellow', 'lime', 'green', 'emerald',
|
|
3
|
+
'teal', 'cyan', 'sky', 'blue', 'indigo', 'violet', 'purple',
|
|
4
|
+
'fuchsia', 'pink', 'rose'
|
|
5
|
+
];
|
|
6
|
+
export const defaultTheme = {
|
|
7
|
+
name: 'minimal',
|
|
8
|
+
accentColor: 'cyan',
|
|
9
|
+
baseColor: 'mist'
|
|
10
|
+
};
|
|
11
|
+
export function randomAccentColor() {
|
|
12
|
+
return accentColors[Math.floor(Math.random() * accentColors.length)];
|
|
13
|
+
}
|
|
14
|
+
export const themeBackgrounds = {
|
|
15
|
+
minimal: 'Minimal',
|
|
16
|
+
blobs: 'Blobs',
|
|
17
|
+
warp: 'Stars',
|
|
18
|
+
matrix: 'Matrix',
|
|
19
|
+
fireflies: 'Fireflies',
|
|
20
|
+
butterflies: 'Butterflies',
|
|
21
|
+
kaleidoscope: 'Kaleidoscope'
|
|
22
|
+
};
|