@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,72 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount } from 'svelte';
|
|
3
|
+
|
|
4
|
+
type Word = { text: string; start: number; end: number };
|
|
5
|
+
type Segment = { start: number; end: number; text: string; words: Word[] };
|
|
6
|
+
|
|
7
|
+
let {
|
|
8
|
+
transcriptUrl,
|
|
9
|
+
currentTime = 0,
|
|
10
|
+
onseek
|
|
11
|
+
}: {
|
|
12
|
+
transcriptUrl: string;
|
|
13
|
+
currentTime?: number;
|
|
14
|
+
onseek?: (time: number) => void;
|
|
15
|
+
} = $props();
|
|
16
|
+
|
|
17
|
+
let segments: Segment[] = $state([]);
|
|
18
|
+
let loaded = $state(false);
|
|
19
|
+
|
|
20
|
+
onMount(() => {
|
|
21
|
+
fetchTranscript();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
async function fetchTranscript() {
|
|
25
|
+
try {
|
|
26
|
+
const res = await fetch(transcriptUrl);
|
|
27
|
+
segments = await res.json();
|
|
28
|
+
} catch {
|
|
29
|
+
segments = [];
|
|
30
|
+
}
|
|
31
|
+
loaded = true;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function formatTime(seconds: number): string {
|
|
35
|
+
const m = Math.floor(seconds / 60);
|
|
36
|
+
const s = Math.floor(seconds % 60);
|
|
37
|
+
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function wordClass(word: Word): string {
|
|
41
|
+
if (currentTime >= word.start && currentTime < word.end) {
|
|
42
|
+
return 'text-accent-500 font-semibold';
|
|
43
|
+
}
|
|
44
|
+
if (word.end <= currentTime) {
|
|
45
|
+
return 'text-base-900 dark:text-base-100';
|
|
46
|
+
}
|
|
47
|
+
return 'text-base-400 dark:text-base-500';
|
|
48
|
+
}
|
|
49
|
+
</script>
|
|
50
|
+
|
|
51
|
+
{#if loaded && segments.length > 0}
|
|
52
|
+
<div class="max-h-[400px] overflow-y-auto rounded-xl px-4 py-6">
|
|
53
|
+
{#each segments as segment, i (segment.start)}
|
|
54
|
+
<p class="mb-2">
|
|
55
|
+
<button
|
|
56
|
+
type="button"
|
|
57
|
+
onclick={() => onseek?.(segment.start)}
|
|
58
|
+
class="text-base-400 dark:text-base-500 mr-1 cursor-pointer font-mono text-xs transition-colors hover:text-accent-500"
|
|
59
|
+
>
|
|
60
|
+
{formatTime(segment.start)}
|
|
61
|
+
</button>
|
|
62
|
+
{#each segment.words as word, wi (`${segment.start}-${wi}`)}
|
|
63
|
+
<button
|
|
64
|
+
type="button"
|
|
65
|
+
onclick={() => onseek?.(word.start)}
|
|
66
|
+
class="{wordClass(word)} mr-1.5 cursor-pointer text-sm leading-relaxed transition-colors hover:text-accent-500"
|
|
67
|
+
>{word.text.trim()}</button>
|
|
68
|
+
{/each}
|
|
69
|
+
</p>
|
|
70
|
+
{/each}
|
|
71
|
+
</div>
|
|
72
|
+
{/if}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
type $$ComponentProps = {
|
|
2
|
+
transcriptUrl: string;
|
|
3
|
+
currentTime?: number;
|
|
4
|
+
onseek?: (time: number) => void;
|
|
5
|
+
};
|
|
6
|
+
declare const VodTranscript: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
7
|
+
type VodTranscript = ReturnType<typeof VodTranscript>;
|
|
8
|
+
export default VodTranscript;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure browser-safe atproto helpers used by the UI components. Does not depend
|
|
3
|
+
* on any session/client state — those flow through the EditorAdapter.
|
|
4
|
+
*/
|
|
5
|
+
export declare function getCDNImageBlobUrl({ did, blob, format }: {
|
|
6
|
+
did: string;
|
|
7
|
+
blob: {
|
|
8
|
+
$type: 'blob';
|
|
9
|
+
ref: {
|
|
10
|
+
$link: string;
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
format?: 'webp' | 'jpeg' | 'png';
|
|
14
|
+
}): string;
|
|
15
|
+
export declare function compressImage(file: File | Blob, maxSize?: number, maxDimension?: number): Promise<{
|
|
16
|
+
blob: Blob;
|
|
17
|
+
aspectRatio: {
|
|
18
|
+
width: number;
|
|
19
|
+
height: number;
|
|
20
|
+
};
|
|
21
|
+
}>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure browser-safe atproto helpers used by the UI components. Does not depend
|
|
3
|
+
* on any session/client state — those flow through the EditorAdapter.
|
|
4
|
+
*/
|
|
5
|
+
export function getCDNImageBlobUrl({ did, blob, format = 'webp' }) {
|
|
6
|
+
return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${blob.ref.$link}@${format}`;
|
|
7
|
+
}
|
|
8
|
+
export function compressImage(file, maxSize = 900 * 1024, maxDimension = 2048) {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
const img = new Image();
|
|
11
|
+
const reader = new FileReader();
|
|
12
|
+
reader.onload = (e) => {
|
|
13
|
+
if (!e.target?.result) {
|
|
14
|
+
return reject(new Error('Failed to read file.'));
|
|
15
|
+
}
|
|
16
|
+
img.src = e.target.result;
|
|
17
|
+
};
|
|
18
|
+
reader.onerror = (err) => reject(err);
|
|
19
|
+
reader.readAsDataURL(file);
|
|
20
|
+
img.onload = () => {
|
|
21
|
+
let width = img.width;
|
|
22
|
+
let height = img.height;
|
|
23
|
+
if (file.size <= maxSize) {
|
|
24
|
+
return resolve({ blob: file, aspectRatio: { width, height } });
|
|
25
|
+
}
|
|
26
|
+
if (width > maxDimension || height > maxDimension) {
|
|
27
|
+
if (width > height) {
|
|
28
|
+
height = Math.round((maxDimension / width) * height);
|
|
29
|
+
width = maxDimension;
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
width = Math.round((maxDimension / height) * width);
|
|
33
|
+
height = maxDimension;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const canvas = document.createElement('canvas');
|
|
37
|
+
canvas.width = width;
|
|
38
|
+
canvas.height = height;
|
|
39
|
+
const ctx = canvas.getContext('2d');
|
|
40
|
+
if (!ctx)
|
|
41
|
+
return reject(new Error('Failed to get canvas context.'));
|
|
42
|
+
ctx.drawImage(img, 0, 0, width, height);
|
|
43
|
+
let quality = 0.9;
|
|
44
|
+
function attemptCompression() {
|
|
45
|
+
canvas.toBlob((blob) => {
|
|
46
|
+
if (!blob)
|
|
47
|
+
return reject(new Error('Compression failed.'));
|
|
48
|
+
if (blob.size <= maxSize || quality < 0.3) {
|
|
49
|
+
resolve({ blob, aspectRatio: { width, height } });
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
quality -= 0.1;
|
|
53
|
+
attemptCompression();
|
|
54
|
+
}
|
|
55
|
+
}, 'image/webp', quality);
|
|
56
|
+
}
|
|
57
|
+
attemptCompression();
|
|
58
|
+
};
|
|
59
|
+
img.onerror = (err) => reject(err);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function validateLink(link: string | undefined, tryAdding?: boolean): string | undefined;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export function validateLink(link, tryAdding = true) {
|
|
2
|
+
if (!link)
|
|
3
|
+
return;
|
|
4
|
+
try {
|
|
5
|
+
new URL(link);
|
|
6
|
+
return link;
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
if (!tryAdding)
|
|
10
|
+
return;
|
|
11
|
+
try {
|
|
12
|
+
link = 'https://' + link;
|
|
13
|
+
new URL(link);
|
|
14
|
+
return link;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { EventData } from '../event-types.js';
|
|
2
|
+
export interface ICalAttendee {
|
|
3
|
+
name: string;
|
|
4
|
+
status: 'going' | 'interested';
|
|
5
|
+
url?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface ICalEvent {
|
|
8
|
+
eventData: EventData;
|
|
9
|
+
uid: string;
|
|
10
|
+
url?: string;
|
|
11
|
+
organizer?: string;
|
|
12
|
+
imageUrl?: string;
|
|
13
|
+
attendees?: ICalAttendee[];
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Generate a complete iCal feed from multiple events.
|
|
17
|
+
*/
|
|
18
|
+
export declare function generateICalFeed(events: ICalEvent[], calendarName: string): string;
|
|
19
|
+
/**
|
|
20
|
+
* Generate iCal content for a single event (for client-side download).
|
|
21
|
+
*/
|
|
22
|
+
export declare function generateICalEvent(eventData: EventData, atUri: string, eventUrl?: string): string;
|
package/dist/cal/ical.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Escape text for iCal fields (RFC 5545 Section 3.3.11).
|
|
3
|
+
* Backslashes, semicolons, commas, and newlines must be escaped.
|
|
4
|
+
*/
|
|
5
|
+
function escapeText(text) {
|
|
6
|
+
return text
|
|
7
|
+
.replace(/\\/g, '\\\\')
|
|
8
|
+
.replace(/;/g, '\\;')
|
|
9
|
+
.replace(/,/g, '\\,')
|
|
10
|
+
.replace(/\n/g, '\\n');
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Fold long lines per RFC 5545 (max 75 octets per line).
|
|
14
|
+
* Continuation lines start with a single space.
|
|
15
|
+
*/
|
|
16
|
+
function foldLine(line) {
|
|
17
|
+
const maxLen = 75;
|
|
18
|
+
if (line.length <= maxLen)
|
|
19
|
+
return line;
|
|
20
|
+
const parts = [];
|
|
21
|
+
parts.push(line.slice(0, maxLen));
|
|
22
|
+
let i = maxLen;
|
|
23
|
+
while (i < line.length) {
|
|
24
|
+
parts.push(' ' + line.slice(i, i + maxLen - 1));
|
|
25
|
+
i += maxLen - 1;
|
|
26
|
+
}
|
|
27
|
+
return parts.join('\r\n');
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Convert an ISO 8601 date string to iCal DATETIME format (UTC).
|
|
31
|
+
* e.g. "2026-02-22T15:00:00Z" -> "20260222T150000Z"
|
|
32
|
+
*/
|
|
33
|
+
function toICalDate(isoString) {
|
|
34
|
+
const d = new Date(isoString);
|
|
35
|
+
const pad = (n) => n.toString().padStart(2, '0');
|
|
36
|
+
return (d.getUTCFullYear().toString() +
|
|
37
|
+
pad(d.getUTCMonth() + 1) +
|
|
38
|
+
pad(d.getUTCDate()) +
|
|
39
|
+
'T' +
|
|
40
|
+
pad(d.getUTCHours()) +
|
|
41
|
+
pad(d.getUTCMinutes()) +
|
|
42
|
+
pad(d.getUTCSeconds()) +
|
|
43
|
+
'Z');
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Extract a location string from event locations array.
|
|
47
|
+
*/
|
|
48
|
+
function getLocationString(locations) {
|
|
49
|
+
if (!locations || locations.length === 0)
|
|
50
|
+
return undefined;
|
|
51
|
+
const loc = locations.find((v) => v.$type === 'community.lexicon.location.address');
|
|
52
|
+
if (!loc)
|
|
53
|
+
return undefined;
|
|
54
|
+
const street = loc.street || undefined;
|
|
55
|
+
const locality = loc.locality || undefined;
|
|
56
|
+
const region = loc.region || undefined;
|
|
57
|
+
const parts = [street, locality, region].filter(Boolean);
|
|
58
|
+
return parts.length > 0 ? parts.join(', ') : undefined;
|
|
59
|
+
}
|
|
60
|
+
function getModeLabel(mode) {
|
|
61
|
+
if (mode.includes('virtual'))
|
|
62
|
+
return 'Virtual';
|
|
63
|
+
if (mode.includes('hybrid'))
|
|
64
|
+
return 'Hybrid';
|
|
65
|
+
if (mode.includes('inperson'))
|
|
66
|
+
return 'In-Person';
|
|
67
|
+
return 'Event';
|
|
68
|
+
}
|
|
69
|
+
function toAbsoluteUrl(pathOrUrl, baseUrl) {
|
|
70
|
+
if (!pathOrUrl)
|
|
71
|
+
return undefined;
|
|
72
|
+
try {
|
|
73
|
+
return new URL(pathOrUrl, baseUrl).toString();
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Generate a single VEVENT block.
|
|
81
|
+
*/
|
|
82
|
+
function generateVEvent(event) {
|
|
83
|
+
const { eventData, uid, url, organizer, imageUrl } = event;
|
|
84
|
+
// Skip events with invalid or missing start dates
|
|
85
|
+
const startTime = new Date(eventData.startsAt);
|
|
86
|
+
if (isNaN(startTime.getTime()))
|
|
87
|
+
return null;
|
|
88
|
+
const lines = [];
|
|
89
|
+
lines.push('BEGIN:VEVENT');
|
|
90
|
+
lines.push(`UID:${escapeText(uid)}`);
|
|
91
|
+
lines.push(`DTSTART:${toICalDate(eventData.startsAt)}`);
|
|
92
|
+
if (eventData.endsAt) {
|
|
93
|
+
lines.push(`DTEND:${toICalDate(eventData.endsAt)}`);
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
// Default to 1 hour duration when no end time is specified
|
|
97
|
+
const defaultEnd = new Date(startTime.getTime() + 60 * 60 * 1000);
|
|
98
|
+
lines.push(`DTEND:${toICalDate(defaultEnd.toISOString())}`);
|
|
99
|
+
}
|
|
100
|
+
lines.push(`SUMMARY:${escapeText(eventData.name)}`);
|
|
101
|
+
// Description: text + links
|
|
102
|
+
const descParts = [];
|
|
103
|
+
if (eventData.description) {
|
|
104
|
+
descParts.push(eventData.description);
|
|
105
|
+
}
|
|
106
|
+
if (eventData.uris && eventData.uris.length > 0) {
|
|
107
|
+
descParts.push('');
|
|
108
|
+
descParts.push('Links:');
|
|
109
|
+
for (const link of eventData.uris) {
|
|
110
|
+
descParts.push(link.name ? `${link.name}: ${link.uri}` : link.uri);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (url) {
|
|
114
|
+
descParts.push('');
|
|
115
|
+
descParts.push(`Event page: ${url}`);
|
|
116
|
+
}
|
|
117
|
+
if (descParts.length > 0) {
|
|
118
|
+
lines.push(`DESCRIPTION:${escapeText(descParts.join('\n'))}`);
|
|
119
|
+
}
|
|
120
|
+
const locationParts = [];
|
|
121
|
+
const room = eventData.additionalData?.room;
|
|
122
|
+
if (typeof room === 'string' && room && room !== 'none') {
|
|
123
|
+
locationParts.push(room);
|
|
124
|
+
}
|
|
125
|
+
const address = getLocationString(eventData.locations);
|
|
126
|
+
if (address) {
|
|
127
|
+
locationParts.push(address);
|
|
128
|
+
}
|
|
129
|
+
if (locationParts.length > 0) {
|
|
130
|
+
lines.push(`LOCATION:${escapeText(locationParts.join(', '))}`);
|
|
131
|
+
}
|
|
132
|
+
if (url) {
|
|
133
|
+
lines.push(`URL:${url}`);
|
|
134
|
+
}
|
|
135
|
+
// Categories from event mode
|
|
136
|
+
if (eventData.mode) {
|
|
137
|
+
lines.push(`CATEGORIES:${escapeText(getModeLabel(eventData.mode))}`);
|
|
138
|
+
}
|
|
139
|
+
// Organizer
|
|
140
|
+
if (organizer) {
|
|
141
|
+
const organizerUrl = toAbsoluteUrl(`/${organizer}`, url) ?? url;
|
|
142
|
+
lines.push(`ORGANIZER;CN=${escapeText(organizer)}:${organizerUrl}`);
|
|
143
|
+
}
|
|
144
|
+
// Attendees
|
|
145
|
+
if (event.attendees) {
|
|
146
|
+
for (const attendee of event.attendees) {
|
|
147
|
+
const partstat = attendee.status === 'going' ? 'ACCEPTED' : 'TENTATIVE';
|
|
148
|
+
const attendeeUrl = toAbsoluteUrl(attendee.url, url) ?? url;
|
|
149
|
+
if (!attendeeUrl)
|
|
150
|
+
continue;
|
|
151
|
+
lines.push(`ATTENDEE;CN=${escapeText(attendee.name)};PARTSTAT=${partstat}:${attendeeUrl}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// Image (supported by Apple Calendar, Google Calendar)
|
|
155
|
+
if (imageUrl) {
|
|
156
|
+
lines.push(`IMAGE;VALUE=URI;DISPLAY=BADGE:${imageUrl}`);
|
|
157
|
+
}
|
|
158
|
+
lines.push(`DTSTAMP:${toICalDate(new Date().toISOString())}`);
|
|
159
|
+
// Reminder 15 minutes before
|
|
160
|
+
lines.push('BEGIN:VALARM');
|
|
161
|
+
lines.push('TRIGGER:-PT15M');
|
|
162
|
+
lines.push('ACTION:DISPLAY');
|
|
163
|
+
lines.push(`DESCRIPTION:${escapeText(eventData.name)}`);
|
|
164
|
+
lines.push('END:VALARM');
|
|
165
|
+
lines.push('END:VEVENT');
|
|
166
|
+
return lines.map(foldLine).join('\r\n');
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Generate a complete iCal feed from multiple events.
|
|
170
|
+
*/
|
|
171
|
+
export function generateICalFeed(events, calendarName) {
|
|
172
|
+
const lines = [];
|
|
173
|
+
lines.push('BEGIN:VCALENDAR');
|
|
174
|
+
lines.push('VERSION:2.0');
|
|
175
|
+
lines.push('PRODID:-//Blento//Events//EN');
|
|
176
|
+
lines.push(`X-WR-CALNAME:${escapeText(calendarName)}`);
|
|
177
|
+
lines.push('CALSCALE:GREGORIAN');
|
|
178
|
+
lines.push('METHOD:PUBLISH');
|
|
179
|
+
const vevents = events.map(generateVEvent).filter((v) => v !== null);
|
|
180
|
+
const result = lines.map(foldLine).join('\r\n') + '\r\n' + vevents.join('\r\n') + '\r\nEND:VCALENDAR\r\n';
|
|
181
|
+
return result;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Generate iCal content for a single event (for client-side download).
|
|
185
|
+
*/
|
|
186
|
+
export function generateICalEvent(eventData, atUri, eventUrl) {
|
|
187
|
+
return generateICalFeed([{ eventData, uid: atUri, url: eventUrl }], eventData.name);
|
|
188
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { BROWSER } from 'esm-env';
|
|
2
|
+
// Lightweight regex-based sanitizer for SSR where DOMPurify is not available.
|
|
3
|
+
// Strips common XSS vectors.
|
|
4
|
+
function regexSanitize(html) {
|
|
5
|
+
return html
|
|
6
|
+
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script\s*>/gi, '')
|
|
7
|
+
.replace(/<iframe\b[^<]*(?:(?!<\/iframe>)<[^<]*)*<\/iframe\s*>/gi, '')
|
|
8
|
+
.replace(/<object\b[^<]*(?:(?!<\/object>)<[^<]*)*<\/object\s*>/gi, '')
|
|
9
|
+
.replace(/<embed\b[^>]*\/?>/gi, '')
|
|
10
|
+
.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style\s*>/gi, '')
|
|
11
|
+
.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '')
|
|
12
|
+
.replace(/href\s*=\s*["']?\s*javascript\s*:/gi, 'href="')
|
|
13
|
+
.replace(/src\s*=\s*["']?\s*javascript\s*:/gi, 'src="');
|
|
14
|
+
}
|
|
15
|
+
let _purify = null;
|
|
16
|
+
if (BROWSER) {
|
|
17
|
+
import('dompurify').then((mod) => {
|
|
18
|
+
_purify = (html, config) => mod.default.sanitize(html, config);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
export function sanitize(dirty, config) {
|
|
22
|
+
if (_purify)
|
|
23
|
+
return _purify(dirty, config);
|
|
24
|
+
return regexSanitize(dirty);
|
|
25
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { EventData } from './event-types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Subset of contrail types and helpers needed by the UI package. Server-side
|
|
4
|
+
* functions (notifyContrailOfUpdate, listEventRecordsFromContrail, etc.) live
|
|
5
|
+
* in the consumer app, not here.
|
|
6
|
+
*/
|
|
7
|
+
export type FlatEventRecord = EventData & {
|
|
8
|
+
cid?: string | null;
|
|
9
|
+
did: string;
|
|
10
|
+
rkey: string;
|
|
11
|
+
uri: string;
|
|
12
|
+
/** Populated when the event was read from a permissioned space. */
|
|
13
|
+
space?: string;
|
|
14
|
+
rsvps?: {
|
|
15
|
+
going?: Array<{
|
|
16
|
+
did: string;
|
|
17
|
+
createdAt?: string;
|
|
18
|
+
}>;
|
|
19
|
+
interested?: Array<{
|
|
20
|
+
did: string;
|
|
21
|
+
createdAt?: string;
|
|
22
|
+
}>;
|
|
23
|
+
notgoing?: Array<{
|
|
24
|
+
did: string;
|
|
25
|
+
createdAt?: string;
|
|
26
|
+
}>;
|
|
27
|
+
};
|
|
28
|
+
rsvpsCount?: number;
|
|
29
|
+
rsvpsGoingCount?: number;
|
|
30
|
+
rsvpsInterestedCount?: number;
|
|
31
|
+
rsvpsNotgoingCount?: number;
|
|
32
|
+
};
|
|
33
|
+
export type HostProfile = {
|
|
34
|
+
did: string;
|
|
35
|
+
handle?: string;
|
|
36
|
+
displayName?: string;
|
|
37
|
+
avatar?: string;
|
|
38
|
+
};
|
|
39
|
+
export type AttendeeInfo = {
|
|
40
|
+
did: string;
|
|
41
|
+
status: 'going' | 'interested';
|
|
42
|
+
avatar?: string;
|
|
43
|
+
name: string;
|
|
44
|
+
handle?: string;
|
|
45
|
+
url: string;
|
|
46
|
+
};
|
|
47
|
+
export declare const RSVP_GOING = "community.lexicon.calendar.rsvp#going";
|
|
48
|
+
export declare const RSVP_INTERESTED = "community.lexicon.calendar.rsvp#interested";
|
|
49
|
+
/** Build the canonical path for an event. Private events (those with a `space`
|
|
50
|
+
* field) live under `/p/<actor>/e/<rkey>/s/<skey>` so the page knows both
|
|
51
|
+
* which event to show and which space to look in. Public events use
|
|
52
|
+
* `/p/<actor>/e/<rkey>`. */
|
|
53
|
+
export declare function eventUrl(event: FlatEventRecord, actor?: string): string;
|
|
54
|
+
export declare function isEventOngoing(startsAt: string, endsAt?: string | null): boolean;
|
package/dist/contrail.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export const RSVP_GOING = 'community.lexicon.calendar.rsvp#going';
|
|
2
|
+
export const RSVP_INTERESTED = 'community.lexicon.calendar.rsvp#interested';
|
|
3
|
+
/** Build the canonical path for an event. Private events (those with a `space`
|
|
4
|
+
* field) live under `/p/<actor>/e/<rkey>/s/<skey>` so the page knows both
|
|
5
|
+
* which event to show and which space to look in. Public events use
|
|
6
|
+
* `/p/<actor>/e/<rkey>`. */
|
|
7
|
+
export function eventUrl(event, actor) {
|
|
8
|
+
const who = actor || event.did;
|
|
9
|
+
if (event.space) {
|
|
10
|
+
const m = event.space.match(/^ats?:\/\/[^/]+\/[^/]+\/([^/]+)$/);
|
|
11
|
+
const skey = m?.[1];
|
|
12
|
+
if (skey)
|
|
13
|
+
return `/p/${who}/e/${event.rkey}/s/${skey}`;
|
|
14
|
+
}
|
|
15
|
+
return `/p/${who}/e/${event.rkey}`;
|
|
16
|
+
}
|
|
17
|
+
export function isEventOngoing(startsAt, endsAt) {
|
|
18
|
+
if (!endsAt)
|
|
19
|
+
return false;
|
|
20
|
+
const now = new Date();
|
|
21
|
+
return new Date(startsAt) <= now && new Date(endsAt) >= now;
|
|
22
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert a datetime-local string (e.g. "2026-04-09T22:00") to a UTC ISO string,
|
|
3
|
+
* interpreting the wall-clock components in the given IANA timezone.
|
|
4
|
+
*/
|
|
5
|
+
export declare function datetimeLocalToISO(dt: string, tz: string): string;
|
|
6
|
+
/**
|
|
7
|
+
* Convert a UTC ISO string to a datetime-local string ("YYYY-MM-DDTHH:mm") whose
|
|
8
|
+
* components represent the wall-clock time in the given IANA timezone.
|
|
9
|
+
*/
|
|
10
|
+
export declare function isoToDatetimeLocalInTz(iso: string, tz: string): string;
|
|
11
|
+
/**
|
|
12
|
+
* Format an ISO timestamp using Intl options, rendered in the event's timezone
|
|
13
|
+
* when available. Falls back to the viewer's local zone for legacy events that
|
|
14
|
+
* predate the timezone field.
|
|
15
|
+
*/
|
|
16
|
+
export declare function formatInTz(iso: string, tz: string | undefined, options: Intl.DateTimeFormatOptions, locale?: string): string;
|
|
17
|
+
/**
|
|
18
|
+
* Returns the parts of an ISO timestamp in the given timezone (or viewer-local
|
|
19
|
+
* when tz is falsy). Useful when the caller wants numeric components like the
|
|
20
|
+
* day-of-month rendered in the event's zone.
|
|
21
|
+
*/
|
|
22
|
+
export declare function partsInTz(iso: string, tz: string | undefined, options: Intl.DateTimeFormatOptions, locale?: string): Record<string, string>;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { parseAbsolute, parseDateTime } from '@internationalized/date';
|
|
2
|
+
/**
|
|
3
|
+
* Convert a datetime-local string (e.g. "2026-04-09T22:00") to a UTC ISO string,
|
|
4
|
+
* interpreting the wall-clock components in the given IANA timezone.
|
|
5
|
+
*/
|
|
6
|
+
export function datetimeLocalToISO(dt, tz) {
|
|
7
|
+
return parseDateTime(dt).toDate(tz).toISOString();
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Convert a UTC ISO string to a datetime-local string ("YYYY-MM-DDTHH:mm") whose
|
|
11
|
+
* components represent the wall-clock time in the given IANA timezone.
|
|
12
|
+
*/
|
|
13
|
+
export function isoToDatetimeLocalInTz(iso, tz) {
|
|
14
|
+
const zdt = parseAbsolute(iso, tz);
|
|
15
|
+
const pad = (n) => n.toString().padStart(2, '0');
|
|
16
|
+
return `${zdt.year}-${pad(zdt.month)}-${pad(zdt.day)}T${pad(zdt.hour)}:${pad(zdt.minute)}`;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Format an ISO timestamp using Intl options, rendered in the event's timezone
|
|
20
|
+
* when available. Falls back to the viewer's local zone for legacy events that
|
|
21
|
+
* predate the timezone field.
|
|
22
|
+
*/
|
|
23
|
+
export function formatInTz(iso, tz, options, locale = 'en-US') {
|
|
24
|
+
const date = new Date(iso);
|
|
25
|
+
return new Intl.DateTimeFormat(locale, { ...options, timeZone: tz || undefined }).format(date);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Returns the parts of an ISO timestamp in the given timezone (or viewer-local
|
|
29
|
+
* when tz is falsy). Useful when the caller wants numeric components like the
|
|
30
|
+
* day-of-month rendered in the event's zone.
|
|
31
|
+
*/
|
|
32
|
+
export function partsInTz(iso, tz, options, locale = 'en-US') {
|
|
33
|
+
const date = new Date(iso);
|
|
34
|
+
const parts = new Intl.DateTimeFormat(locale, {
|
|
35
|
+
...options,
|
|
36
|
+
timeZone: tz || undefined
|
|
37
|
+
}).formatToParts(date);
|
|
38
|
+
const out = {};
|
|
39
|
+
for (const p of parts)
|
|
40
|
+
if (p.type !== 'literal')
|
|
41
|
+
out[p.type] = p.value;
|
|
42
|
+
return out;
|
|
43
|
+
}
|