@atmo-dev/kit 0.6.0 → 0.7.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/README.md +9 -0
- package/dist/components/IconRail.svelte +18 -8
- package/dist/components/IconRail.svelte.d.ts +1 -1
- package/dist/events/EventCard.svelte +2 -2
- package/dist/events/EventView.svelte +3 -3
- package/dist/events/event-media.d.ts +16 -0
- package/dist/events/event-media.js +45 -0
- package/dist/events/event-types.d.ts +2 -0
- package/dist/events-data.d.ts +1 -1
- package/dist/events-data.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,6 +9,13 @@ Svelte 5 and Tailwind CSS 4 components for Atmo apps.
|
|
|
9
9
|
- Releases: `pnpm changeset`, then merge the generated release PR. [Details](.changeset/README.md).
|
|
10
10
|
- License: [MIT](LICENSE).
|
|
11
11
|
|
|
12
|
+
## Navigation
|
|
13
|
+
|
|
14
|
+
`IconRail` items accept a `variant` from `Button` (`primary`, `secondary`, `ghost`,
|
|
15
|
+
`link`); omitted variants remain `ghost`. Use `primary: true, variant: 'primary'`
|
|
16
|
+
for a separated, filled creation action in the rail, mobile bar or drawer. The
|
|
17
|
+
existing `primary` flag alone retains its subtle accent/spacing behavior.
|
|
18
|
+
|
|
12
19
|
## AT record metadata (AT Tags)
|
|
13
20
|
|
|
14
21
|
Use `AtTags` to let other clients discover which AT Protocol records and identities
|
|
@@ -114,6 +121,8 @@ The event UI formerly in `@atmo-dev/events-ui` lives in this package:
|
|
|
114
121
|
- `@atmo-dev/kit/events-data`: structural event types, adapter contracts, conference/date helpers, URL helpers, theme values, linked-record types and thumbnail selection. This entry is safe in plain Node and server routes: no Svelte, CSS, maps or players are loaded. Browser helpers such as `compressImage` only access browser APIs when called.
|
|
115
122
|
- `@atmo-dev/kit/events/editor`: `buildEventRecord` and `buildThumbnailMedia` for hosts that need explicit record construction. This separate entry includes browser-oriented thumbnail code; it is not part of the pure data API.
|
|
116
123
|
|
|
124
|
+
Event cards and covers prefer native `media` images, then an HTTPS `poster.url`, then a URI named `Event Image` (laugh.town compatibility). `getEventExternalImage(event)` from `events-data` returns `{ url, alt, aspectRatio? }` or `null`, validating URLs and optional poster dimensions. It performs no IO; apps must apply their own allowlist/redirect policy before fetching an external image server-side.
|
|
125
|
+
|
|
117
126
|
Apps implement `EditorAdapter` for authentication prompts, repository IO, uploads, save/delete navigation, and optional linked-record transport/indexing notifications. Keep sessions, Cloudflare bindings, app routes, server endpoints, caching and moderation in the app. The editor/view never imports a consumer's app state. Existing `EventView` page-data shape and RSVP behavior are preserved in this migration.
|
|
118
127
|
|
|
119
128
|
Full event UI requires optional peers (ordinary Kit consumers do not):
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script lang="ts" module>
|
|
2
2
|
import type { IconNode } from '@sketchyicons/svelte';
|
|
3
3
|
import type { ButtonProps } from './Button.svelte';
|
|
4
|
-
export type IconRailItem = Pick<ButtonProps, 'href' | 'onclick' | 'disabled'> & {
|
|
4
|
+
export type IconRailItem = Pick<ButtonProps, 'href' | 'onclick' | 'disabled' | 'variant'> & {
|
|
5
5
|
label: string;
|
|
6
6
|
icon: IconNode;
|
|
7
7
|
active?: boolean;
|
|
@@ -77,7 +77,7 @@
|
|
|
77
77
|
<Button
|
|
78
78
|
href={item.href}
|
|
79
79
|
disabled={item.disabled}
|
|
80
|
-
variant=
|
|
80
|
+
variant={item.variant ?? 'ghost'}
|
|
81
81
|
aria-current={item.active ? 'page' : undefined}
|
|
82
82
|
data-part="item"
|
|
83
83
|
onclick={(event) => {
|
|
@@ -85,8 +85,11 @@
|
|
|
85
85
|
item.onclick?.(event);
|
|
86
86
|
}}
|
|
87
87
|
class={cn(
|
|
88
|
-
'min-h-11 w-full justify-start gap-3 px-3 py-3 text-sm
|
|
89
|
-
item.
|
|
88
|
+
'min-h-11 w-full justify-start gap-3 px-3 py-3 text-sm',
|
|
89
|
+
(!item.variant || item.variant === 'ghost') &&
|
|
90
|
+
'aria-[current=page]:bg-recess aria-[current=page]:text-action',
|
|
91
|
+
item.primary && 'mt-3',
|
|
92
|
+
item.primary && (!item.variant || item.variant === 'ghost') && 'text-action'
|
|
90
93
|
)}
|
|
91
94
|
>
|
|
92
95
|
<Icon img={item.icon} size={22} />
|
|
@@ -128,15 +131,22 @@
|
|
|
128
131
|
{...props}
|
|
129
132
|
href={item.href}
|
|
130
133
|
disabled={item.disabled}
|
|
131
|
-
variant=
|
|
134
|
+
variant={item.variant ?? 'ghost'}
|
|
132
135
|
size="icon"
|
|
133
136
|
aria-label={`${item.label}${item.badge ? (typeof item.badge === 'number' ? `, ${item.badge} unread` : ', unread') : ''}`}
|
|
134
137
|
aria-current={item.active ? 'page' : undefined}
|
|
135
138
|
data-part="item"
|
|
136
139
|
class={cn(
|
|
137
|
-
'relative size-11 rounded-(--atmo-radius-button)
|
|
138
|
-
item.
|
|
139
|
-
|
|
140
|
+
'relative size-11 rounded-(--atmo-radius-button) max-sm:size-10',
|
|
141
|
+
(!item.variant || item.variant === 'ghost') &&
|
|
142
|
+
'text-muted hover:bg-recess/55 aria-[current=page]:text-action',
|
|
143
|
+
item.primary && 'mt-2 max-sm:mt-0',
|
|
144
|
+
item.primary &&
|
|
145
|
+
(!item.variant || item.variant === 'ghost') &&
|
|
146
|
+
'text-action hover:text-action',
|
|
147
|
+
backed &&
|
|
148
|
+
(!item.variant || item.variant === 'ghost') &&
|
|
149
|
+
'bg-recess/45 hover:bg-recess/75'
|
|
140
150
|
)}
|
|
141
151
|
>
|
|
142
152
|
<Icon img={item.icon} size={22} />{#if item.badge}<span
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { IconNode } from '@sketchyicons/svelte';
|
|
2
2
|
import type { ButtonProps } from './Button.svelte';
|
|
3
|
-
export type IconRailItem = Pick<ButtonProps, 'href' | 'onclick' | 'disabled'> & {
|
|
3
|
+
export type IconRailItem = Pick<ButtonProps, 'href' | 'onclick' | 'disabled' | 'variant'> & {
|
|
4
4
|
label: string;
|
|
5
5
|
icon: IconNode;
|
|
6
6
|
active?: boolean;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { getCDNImageBlobUrl } from './atproto-helpers.js';
|
|
3
|
-
import { getEventThumbnail } from './event-media.js';
|
|
3
|
+
import { getEventThumbnail, getEventExternalImage } from './event-media.js';
|
|
4
4
|
import { eventUrl, isEventOngoing, type FlatEventRecord } from './contrail.js';
|
|
5
5
|
import Avatar from 'svelte-boring-avatars';
|
|
6
6
|
import { Badge, Icon } from '../index.js';
|
|
@@ -75,7 +75,7 @@
|
|
|
75
75
|
if (url) return { url, alt: banner.alt || event.name };
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
return
|
|
78
|
+
return getEventExternalImage(event);
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
let thumbnail = $derived(getThumbnail(event));
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { eventUrl, isEventOngoing, type FlatEventRecord } from './contrail.js';
|
|
3
3
|
import type { EventViewData } from './view-data.js';
|
|
4
4
|
import { getCDNImageBlobUrl } from './atproto-helpers.js';
|
|
5
|
-
import { getEventThumbnail } from './event-media.js';
|
|
5
|
+
import { getEventThumbnail, getEventExternalImage } from './event-media.js';
|
|
6
6
|
import { Button, ToggleGroup, ToggleGroupItem } from '../index.js';
|
|
7
7
|
import ShareModal from './ShareModal.svelte';
|
|
8
8
|
import EventComments from './EventComments.svelte';
|
|
@@ -159,8 +159,8 @@
|
|
|
159
159
|
return { url, alt: media.alt || eventData.name, aspectRatio: media.aspect_ratio };
|
|
160
160
|
});
|
|
161
161
|
|
|
162
|
-
// Prefer
|
|
163
|
-
let displayImage = $derived(thumbnailImage ?? bannerImage);
|
|
162
|
+
// Prefer native media, then external posters.
|
|
163
|
+
let displayImage = $derived(thumbnailImage ?? bannerImage ?? getEventExternalImage(eventData));
|
|
164
164
|
let imageAspectRatio = $derived.by(() => {
|
|
165
165
|
const ratio = displayImage?.aspectRatio;
|
|
166
166
|
if (!ratio || ratio.width <= 0 || ratio.height <= 0) return null;
|
|
@@ -2,3 +2,19 @@
|
|
|
2
2
|
export declare function getEventThumbnail<T extends {
|
|
3
3
|
role?: unknown;
|
|
4
4
|
}>(media: readonly T[] | null | undefined): T | undefined;
|
|
5
|
+
export type ExternalEventImage = {
|
|
6
|
+
url: string;
|
|
7
|
+
alt: string;
|
|
8
|
+
aspectRatio?: {
|
|
9
|
+
width: number;
|
|
10
|
+
height: number;
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
/** External poster compatibility (e.g. laugh.town). Prefer native media first.
|
|
14
|
+
* Validates display URLs only; apps must apply their own server-fetch policy.
|
|
15
|
+
*/
|
|
16
|
+
export declare function getEventExternalImage(event: {
|
|
17
|
+
name: string;
|
|
18
|
+
poster?: unknown;
|
|
19
|
+
uris?: unknown;
|
|
20
|
+
}): ExternalEventImage | null;
|
|
@@ -2,3 +2,48 @@
|
|
|
2
2
|
export function getEventThumbnail(media) {
|
|
3
3
|
return media?.find((m) => m.role === 'thumbnail') ?? media?.find((m) => m.role === 'flyer');
|
|
4
4
|
}
|
|
5
|
+
function imageUrl(value) {
|
|
6
|
+
if (typeof value !== 'string')
|
|
7
|
+
return null;
|
|
8
|
+
try {
|
|
9
|
+
const url = new URL(value);
|
|
10
|
+
if (url.protocol !== 'https:' || url.username || url.password)
|
|
11
|
+
return null;
|
|
12
|
+
return url.href;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** External poster compatibility (e.g. laugh.town). Prefer native media first.
|
|
19
|
+
* Validates display URLs only; apps must apply their own server-fetch policy.
|
|
20
|
+
*/
|
|
21
|
+
export function getEventExternalImage(event) {
|
|
22
|
+
const poster = event.poster;
|
|
23
|
+
if (poster && typeof poster === 'object' && 'url' in poster) {
|
|
24
|
+
const url = imageUrl(poster.url);
|
|
25
|
+
if (url) {
|
|
26
|
+
const width = 'width' in poster ? poster.width : undefined;
|
|
27
|
+
const height = 'height' in poster ? poster.height : undefined;
|
|
28
|
+
const aspectRatio = typeof width === 'number' &&
|
|
29
|
+
Number.isFinite(width) &&
|
|
30
|
+
width > 0 &&
|
|
31
|
+
typeof height === 'number' &&
|
|
32
|
+
Number.isFinite(height) &&
|
|
33
|
+
height > 0
|
|
34
|
+
? { width, height }
|
|
35
|
+
: undefined;
|
|
36
|
+
return { url, alt: event.name, aspectRatio };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (Array.isArray(event.uris)) {
|
|
40
|
+
for (const link of event.uris) {
|
|
41
|
+
if (!link || typeof link !== 'object' || link.name !== 'Event Image')
|
|
42
|
+
continue;
|
|
43
|
+
const url = imageUrl(link.uri);
|
|
44
|
+
if (url)
|
|
45
|
+
return { url, alt: event.name };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
package/dist/events-data.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ export * from './events/conference.js';
|
|
|
9
9
|
export * from './events/linked-records.js';
|
|
10
10
|
export * from './events/date-format.js';
|
|
11
11
|
export * from './events/theme.js';
|
|
12
|
-
export { getEventThumbnail } from './events/event-media.js';
|
|
12
|
+
export { getEventThumbnail, getEventExternalImage, type ExternalEventImage } from './events/event-media.js';
|
|
13
13
|
export { getCDNImageBlobUrl, compressImage } from './events/atproto-helpers.js';
|
|
14
14
|
export { getProfileUrl, getLocalProfileUrl } from './events/profile-url.js';
|
|
15
15
|
export { normalizeCountryCode, normalizeEventLocations, LocationValidationError } from './events/editor/location.js';
|
package/dist/events-data.js
CHANGED
|
@@ -3,7 +3,7 @@ export * from './events/conference.js';
|
|
|
3
3
|
export * from './events/linked-records.js';
|
|
4
4
|
export * from './events/date-format.js';
|
|
5
5
|
export * from './events/theme.js';
|
|
6
|
-
export { getEventThumbnail } from './events/event-media.js';
|
|
6
|
+
export { getEventThumbnail, getEventExternalImage } from './events/event-media.js';
|
|
7
7
|
export { getCDNImageBlobUrl, compressImage } from './events/atproto-helpers.js';
|
|
8
8
|
export { getProfileUrl, getLocalProfileUrl } from './events/profile-url.js';
|
|
9
9
|
export { normalizeCountryCode, normalizeEventLocations, LocationValidationError } from './events/editor/location.js';
|