···
21
21
.LinkPreview::before {
22
22
content: "";
23
23
position: absolute;
24
24
-
inset: 0;
25
25
-
border: 1px solid transparent;
26
26
-
border-radius: 8px;
24
24
+
inset: -1px;
25
25
+
border: 2px solid transparent;
26
26
+
border-radius: 9px;
27
27
pointer-events: none;
28
28
-
z-index: -1;
28
28
+
z-index: 1;
29
29
transition: all 0.2s;
30
30
}
31
31
···
54
54
55
55
.LinkPreview-thumb {
56
56
width: 100%;
57
57
-
height: auto;
58
58
-
max-height: 400px;
57
57
+
aspect-ratio: 1.91 / 1;
59
58
object-fit: cover;
60
59
display: block;
61
60
}
···
5
5
import "./embeds/TrailEmbed.css";
6
6
import "@/app/TrailCard.css";
7
7
8
8
-
type EmbedPromise = Promise<React.ReactElement | null>;
8
8
+
type EmbedPromise = Promise<React.ReactElement>;
9
9
export type EmbedCache = Map<string, EmbedPromise>;
10
10
export const EmbedCacheContext = createContext<[EmbedCache | null, (c: EmbedCache) => void]>([
11
11
null,
···
37
37
const isTrail = isTrailUri(uri);
38
38
const promise = getPromise(uri, cache);
39
39
const embed = use(promise);
40
40
-
41
41
-
if (!embed) {
42
42
-
return (
43
43
-
<div className="BlueskyPostEmbed-container">
44
44
-
<div className="BlueskyPostEmbed error">
45
45
-
<div className="BlueskyPostEmbed-error">couldn't load embed</div>
46
46
-
</div>
47
47
-
{onDelete && (
48
48
-
<button onClick={onDelete} className="BlueskyPostEmbed-deleteButton" title="remove link">
49
49
-
×
50
50
-
</button>
51
51
-
)}
52
52
-
</div>
53
53
-
);
54
54
-
}
55
40
56
41
if (onDelete) {
57
42
const containerClass = isTrail ? "TrailEmbed-container" : "BlueskyPostEmbed-container";
···
54
54
: isVisited
55
55
? "TrailStop--visited"
56
56
: ""
57
57
-
} ${isReorderActive && isEditing ? "TrailStop--reorderActive" : ""} ${isEditing ? "TrailStop--editing" : ""}`}
58
58
-
onClick={() => isClickable && onGoToStop(index)}
57
57
+
} ${isClickable && !isCurrent ? "TrailStop--clickable" : ""} ${isReorderActive && isEditing ? "TrailStop--reorderActive" : ""} ${isEditing ? "TrailStop--editing" : ""}`}
58
58
+
onClick={() => {
59
59
+
if (isClickable && !isCurrent) {
60
60
+
onGoToStop(index);
61
61
+
}
62
62
+
}}
59
63
onKeyDown={(e) => {
60
64
if (isClickable && (e.key === "Enter" || e.key === " ") && e.target === e.currentTarget) {
61
65
e.preventDefault();
···
67
71
onGoToStop(index);
68
72
}
69
73
}}
70
70
-
style={{ cursor: isClickable ? "pointer" : "default" }}
71
74
tabIndex={isClickable && !isEditing ? 0 : undefined}
72
75
role={isClickable && !isEditing ? "button" : undefined}
73
76
aria-label={isClickable && !isEditing ? stopLabel : undefined}
···
1
1
"use client";
2
2
3
3
import { Suspense, use, useCallback } from "react";
4
4
+
import { ErrorBoundary } from "react-error-boundary";
4
5
import { useEditMode } from "./EditModeContext";
5
6
import type { TrailStop } from "@/data/queries";
6
7
import { EmbedCacheContext, StopEmbed } from "./StopEmbed";
···
9
10
import { blueskyUrlToAtUri } from "./utils/bluesky";
10
11
import "./TrailStopCard.css";
11
12
12
12
-
function EmbedLoading() {
13
13
+
function EmbedLoading({ onDelete }: { onDelete?: () => void }) {
13
14
return (
14
15
<div className="BlueskyPostEmbed-container">
15
16
<div className="BlueskyPostEmbed loading">
16
17
<div className="BlueskyPostEmbed-loading">loading...</div>
17
18
</div>
19
19
+
{onDelete && (
20
20
+
<button onClick={onDelete} className="BlueskyPostEmbed-deleteButton" title="remove link">
21
21
+
×
22
22
+
</button>
23
23
+
)}
24
24
+
</div>
25
25
+
);
26
26
+
}
27
27
+
28
28
+
function EmbedError({ onDelete }: { onDelete?: () => void }) {
29
29
+
return (
30
30
+
<div className="BlueskyPostEmbed-container">
31
31
+
<div className="BlueskyPostEmbed error">
32
32
+
<div className="BlueskyPostEmbed-error">couldn't load embed</div>
33
33
+
</div>
34
34
+
{onDelete && (
35
35
+
<button onClick={onDelete} className="BlueskyPostEmbed-deleteButton" title="remove link">
36
36
+
×
37
37
+
</button>
38
38
+
)}
18
39
</div>
19
40
);
20
41
}
···
170
191
className="TrailStopCard-embed"
171
192
style={isEditing ? { opacity: 0.9, marginTop: "0.5rem" } : undefined}
172
193
>
173
173
-
<Suspense fallback={<EmbedLoading />}>
174
174
-
<StopEmbed
175
175
-
external={stop.external}
176
176
-
onDelete={isEditing ? handleRemoveLink : undefined}
177
177
-
/>
178
178
-
</Suspense>
194
194
+
<ErrorBoundary
195
195
+
fallbackRender={() => (
196
196
+
<EmbedError onDelete={isEditing ? handleRemoveLink : undefined} />
197
197
+
)}
198
198
+
>
199
199
+
<Suspense
200
200
+
fallback={<EmbedLoading onDelete={isEditing ? handleRemoveLink : undefined} />}
201
201
+
>
202
202
+
<StopEmbed
203
203
+
external={stop.external}
204
204
+
onDelete={isEditing ? handleRemoveLink : undefined}
205
205
+
/>
206
206
+
</Suspense>
207
207
+
</ErrorBoundary>
179
208
</div>
180
209
)}
181
210
</div>
···
11
11
cleanHandle: string;
12
12
rkey: string;
13
13
currentUserDid: string | null;
14
14
-
initialEmbeds?: Array<[string, Promise<React.ReactElement | null>]>;
14
14
+
initialEmbeds?: Array<[string, Promise<React.ReactElement>]>;
15
15
};
16
16
17
17
type ViewMode = "overview" | "walk";
···
22
22
}: {
23
23
revealed: boolean;
24
24
children: ReactNode;
25
25
-
initialEmbed?: [string, Promise<React.ReactElement | null>];
25
25
+
initialEmbed?: [string, Promise<React.ReactElement>];
26
26
}) {
27
27
const cacheAndSetCache = useState<EmbedCache>(() => new Map(initialEmbed ? [initialEmbed] : []));
28
28
return (
···
41
41
onPublish?: () => void;
42
42
publishError?: string[] | null;
43
43
isPublishing?: boolean;
44
44
-
initialEmbeds?: Array<[string, Promise<React.ReactElement | null>]>;
44
44
+
initialEmbeds?: Array<[string, Promise<React.ReactElement>]>;
45
45
};
46
46
47
47
export function TrailWalk({
···
270
270
? initialEmbedsMap.get(embedUri)
271
271
? ([embedUri, initialEmbedsMap.get(embedUri)!] as [
272
272
string,
273
273
-
Promise<React.ReactElement | null>,
273
273
+
Promise<React.ReactElement>,
274
274
])
275
275
: undefined
276
276
: undefined;
···
1
1
.BlueskyPostEmbed {
2
2
-
border: 1px solid var(--accent-color, #999);
2
2
+
border: 1px solid var(--border-subtle);
3
3
border-radius: 8px;
4
4
padding: 16px;
5
5
background: rgba(0, 0, 0, 0.02);
···
11
11
width: 100%;
12
12
position: relative;
13
13
isolation: isolate;
14
14
-
cursor: default;
14
14
+
transition: border-color 0.2s;
15
15
}
16
16
17
17
.BlueskyPostEmbed::before {
18
18
content: "";
19
19
position: absolute;
20
20
-
inset: 0;
21
21
-
border: 1px solid var(--accent-color, #999);
22
22
-
border-radius: 8px;
23
23
-
filter: var(--user-content-filter);
24
24
-
z-index: -1;
20
20
+
inset: -1px;
21
21
+
border: 2px solid transparent;
22
22
+
border-radius: 9px;
23
23
+
pointer-events: none;
24
24
+
z-index: 1;
25
25
+
transition: all 0.2s;
26
26
+
}
27
27
+
28
28
+
@media (hover: hover) {
29
29
+
.BlueskyPostEmbed:hover::before {
30
30
+
border-color: var(--accent-color, #999);
31
31
+
filter: var(--user-content-filter);
32
32
+
}
25
33
}
26
34
27
35
@media (prefers-color-scheme: dark) {
···
36
44
text-align: center;
37
45
color: var(--text-secondary);
38
46
font-size: 14px;
47
47
+
}
48
48
+
49
49
+
.BlueskyPostEmbed.loading::before,
50
50
+
.BlueskyPostEmbed.error::before {
51
51
+
display: none;
39
52
}
40
53
41
54
.BlueskyPostEmbed-loading {
···
270
283
/* Delete button for edit mode */
271
284
.BlueskyPostEmbed-container {
272
285
position: relative;
286
286
+
margin-top: 12px;
273
287
}
274
288
275
289
.BlueskyPostEmbed-deleteButton {
···
40
40
]);
41
41
42
42
// Preload embeds for all stops that have external links
43
43
-
const initialEmbeds: Array<[string, Promise<React.ReactElement | null>]> = trail.stops
43
43
+
const initialEmbeds: Array<[string, Promise<React.ReactElement>]> = trail.stops
44
44
.filter((stop) => stop.external?.uri)
45
45
.map((stop) => [stop.external!.uri, loadEmbed(stop.external!.uri)]);
46
46
···
66
66
return {
67
67
type: "youtube_video",
68
68
source: "youtube",
69
69
-
playerUri: `https://www.youtube.com/embed/${videoId}?start=${seek}&autoplay=1`,
69
69
+
playerUri: `https://www.youtube.com/embed/${videoId}?start=${seek}`,
70
70
};
71
71
}
72
72
}
···
90
90
type: isShorts ? "youtube_short" : "youtube_video",
91
91
source: isShorts ? "youtubeShorts" : "youtube",
92
92
hideDetails: isShorts ? true : undefined,
93
93
-
playerUri: `https://www.youtube.com/embed/${videoId}?start=${seek}&autoplay=1`,
93
93
+
playerUri: `https://www.youtube.com/embed/${videoId}?start=${seek}`,
94
94
};
95
95
}
96
96
}
···
238
238
return {
239
239
type: "vimeo_video",
240
240
source: "vimeo",
241
241
-
playerUri: `https://player.vimeo.com/video/${videoId}?autoplay=1`,
241
241
+
playerUri: `https://player.vimeo.com/video/${videoId}`,
242
242
};
243
243
}
244
244
}
···
38
38
type Props = {
39
39
rkey: string;
40
40
initialDraft: DraftDetail;
41
41
-
initialEmbeds?: Array<[string, Promise<React.ReactElement | null>]>;
41
41
+
initialEmbeds?: Array<[string, Promise<React.ReactElement>]>;
42
42
};
43
43
44
44
export function DraftEditor({ rkey, initialDraft, initialEmbeds }: Props) {
···
18
18
}
19
19
20
20
// Preload embeds for all stops that have external links
21
21
-
const initialEmbeds: Array<[string, Promise<React.ReactElement | null>]> = draft.stops
21
21
+
const initialEmbeds: Array<[string, Promise<React.ReactElement>]> = draft.stops
22
22
.filter((stop) => stop.external?.uri)
23
23
.map((stop) => [stop.external!.uri, loadEmbed(stop.external!.uri)]);
24
24
···
1
1
"use server";
2
2
3
3
import "server-only";
4
4
+
import { cacheLife, cacheTag } from "next/cache";
4
5
import { Client, type l } from "@atproto/lex";
5
6
import * as getPostThread from "@/lib/lexicons/app/bsky/feed/getPostThread";
6
7
import * as feedDefs from "@/lib/lexicons/app/bsky/feed/defs";
···
29
30
);
30
31
}
31
32
32
32
-
async function getBlueskyPost(uri: string): Promise<BlueskyPost | null> {
33
33
-
const atUri = uri.startsWith("at://")
34
34
-
? uri
35
35
-
: uri.startsWith("http")
36
36
-
? blueskyUrlToAtUri(uri)
37
37
-
: null;
38
38
-
if (!atUri) return null;
33
33
+
// Cached fetch for Bluesky posts - throws on failure
34
34
+
async function fetchBlueskyPost(atUri: string): Promise<BlueskyPost> {
35
35
+
"use cache: redis";
36
36
+
cacheTag(`embed:bsky:${atUri}`);
37
37
+
cacheLife("days");
39
38
40
40
-
try {
41
41
-
const atprotoClient = new Client("https://public.api.bsky.app");
42
42
-
const result = await atprotoClient.call(getPostThread.main, {
43
43
-
uri: atUri as l.AtUri,
44
44
-
depth: 0,
45
45
-
parentHeight: 0,
46
46
-
});
47
47
-
if (feedDefs.threadViewPost.$check(result.thread)) {
48
48
-
return result.thread;
49
49
-
}
50
50
-
return null;
51
51
-
} catch (error) {
52
52
-
console.error("Failed to fetch Bluesky post:", error);
53
53
-
return null;
39
39
+
const atprotoClient = new Client("https://public.api.bsky.app");
40
40
+
const result = await atprotoClient.call(getPostThread.main, {
41
41
+
uri: atUri as l.AtUri,
42
42
+
depth: 0,
43
43
+
parentHeight: 0,
44
44
+
});
45
45
+
if (feedDefs.threadViewPost.$check(result.thread)) {
46
46
+
// Serialize to strip non-plain objects (like CID)
47
47
+
return JSON.parse(JSON.stringify(result.thread)) as BlueskyPost;
54
48
}
49
49
+
throw new Error(`Invalid thread response for ${atUri}`);
55
50
}
56
51
57
57
-
async function getLinkMetadata(url: string): Promise<LinkMetadata | null> {
58
58
-
try {
59
59
-
const response = await fetch(
60
60
-
`https://cardyb.bsky.app/v1/extract?url=${encodeURIComponent(url)}`,
61
61
-
);
62
62
-
if (!response.ok) return null;
52
52
+
// Cached fetch for link metadata - throws on failure
53
53
+
async function fetchLinkMetadata(url: string): Promise<LinkMetadata> {
54
54
+
"use cache: redis";
55
55
+
cacheTag(`embed:link:${url}`);
56
56
+
cacheLife("days");
63
57
64
64
-
const data = await response.json();
65
65
-
return {
66
66
-
uri: url,
67
67
-
title: data.title || url,
68
68
-
description: data.description || "",
69
69
-
thumb: data.image || undefined,
70
70
-
};
71
71
-
} catch (error) {
72
72
-
console.error("Failed to fetch link metadata:", error);
73
73
-
return null;
58
58
+
const response = await fetch(`https://cardyb.bsky.app/v1/extract?url=${encodeURIComponent(url)}`);
59
59
+
if (!response.ok) {
60
60
+
throw new Error(`Failed to fetch link metadata: ${response.status}`);
74
61
}
62
62
+
63
63
+
const data = await response.json();
64
64
+
return {
65
65
+
uri: url,
66
66
+
title: data.title || url,
67
67
+
description: data.description || "",
68
68
+
thumb: data.image || undefined,
69
69
+
};
75
70
}
76
71
77
77
-
export async function loadEmbed(uri: string): Promise<React.ReactElement | null> {
72
72
+
export async function loadEmbed(uri: string): Promise<React.ReactElement> {
78
73
if (isTrailUri(uri)) {
79
74
const trail = await loadTrailCardByUri(uri);
80
80
-
if (!trail) return null;
75
75
+
if (!trail) throw new Error(`Trail not found: ${uri}`);
81
76
return <TrailCard {...trail} />;
82
77
}
83
78
84
79
if (isBlueskyPostUri(uri)) {
85
85
-
const post = await getBlueskyPost(uri);
86
86
-
if (!post) return null;
87
87
-
// Serialize to strip non-plain objects (like CID)
88
88
-
const plainPost = JSON.parse(JSON.stringify(post)) as typeof post;
89
89
-
return <BlueskyPostEmbed post={plainPost} />;
80
80
+
const atUri = uri.startsWith("at://")
81
81
+
? uri
82
82
+
: uri.startsWith("http")
83
83
+
? blueskyUrlToAtUri(uri)
84
84
+
: null;
85
85
+
if (!atUri) throw new Error(`Invalid Bluesky URI: ${uri}`);
86
86
+
87
87
+
const post = await fetchBlueskyPost(atUri);
88
88
+
return <BlueskyPostEmbed post={post} />;
90
89
}
91
90
92
91
// Regular link
93
93
-
const metadata = await getLinkMetadata(uri);
94
94
-
if (!metadata) return null;
92
92
+
const metadata = await fetchLinkMetadata(uri);
95
93
return (
96
94
<LinkPreview
97
95
external={{
···
36
36
"pg": "^8.16.3",
37
37
"react": "^19",
38
38
"react-dom": "^19",
39
39
+
"react-error-boundary": "^6.0.0",
39
40
"ws": "^8.18.3"
40
41
},
41
42
"devDependencies": {
···
869
870
},
870
871
"engines": {
871
872
"node": ">=6.0.0"
873
873
+
}
874
874
+
},
875
875
+
"node_modules/@babel/runtime": {
876
876
+
"version": "7.28.4",
877
877
+
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
878
878
+
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
879
879
+
"license": "MIT",
880
880
+
"engines": {
881
881
+
"node": ">=6.9.0"
872
882
}
873
883
},
874
884
"node_modules/@babel/template": {
···
6884
6894
},
6885
6895
"peerDependencies": {
6886
6896
"react": "^19.2.1"
6897
6897
+
}
6898
6898
+
},
6899
6899
+
"node_modules/react-error-boundary": {
6900
6900
+
"version": "6.0.0",
6901
6901
+
"resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-6.0.0.tgz",
6902
6902
+
"integrity": "sha512-gdlJjD7NWr0IfkPlaREN2d9uUZUlksrfOx7SX62VRerwXbMY6ftGCIZua1VG1aXFNOimhISsTq+Owp725b9SiA==",
6903
6903
+
"license": "MIT",
6904
6904
+
"dependencies": {
6905
6905
+
"@babel/runtime": "^7.12.5"
6906
6906
+
},
6907
6907
+
"peerDependencies": {
6908
6908
+
"react": ">=16.13.1"
6887
6909
}
6888
6910
},
6889
6911
"node_modules/react-remove-scroll": {
···
53
53
"pg": "^8.16.3",
54
54
"react": "^19",
55
55
"react-dom": "^19",
56
56
+
"react-error-boundary": "^6.0.0",
56
57
"ws": "^8.18.3"
57
58
},
58
59
"devDependencies": {