tangled
alpha
login
or
join now
margin.at
/
margin
90
fork
atom
Margin is an open annotation layer for the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
90
fork
atom
overview
issues
4
pulls
1
pipelines
more bug fixes and additions
scanash.com
1 month ago
a226bc2e
ab4b436d
+97
-48
4 changed files
expand all
collapse all
unified
split
web
src
api
client.ts
components
common
Card.tsx
modals
ExternalLinkModal.tsx
views
content
AnnotationDetail.tsx
+2
-1
web/src/api/client.ts
···
217
217
target: target,
218
218
viewer: raw.viewer || { like: raw.viewerHasLiked ? "true" : undefined },
219
219
motivation: raw.motivation || "highlighting",
220
220
+
parentUri: (raw as Record<string, unknown>).inReplyTo as string | undefined,
220
221
};
221
222
}
222
223
···
699
700
700
701
export async function getCollections(creator?: string): Promise<Collection[]> {
701
702
try {
702
702
-
const query = creator ? `?creator=${encodeURIComponent(creator)}` : "";
703
703
+
const query = creator ? `?author=${encodeURIComponent(creator)}` : "";
703
704
const res = await apiRequest(`/api/collections${query}`);
704
705
if (!res.ok) throw new Error("Failed to fetch collections");
705
706
const data = await res.json();
+44
-10
web/src/components/common/Card.tsx
···
83
83
try {
84
84
const hostname = safeUrlHostname(url);
85
85
if (hostname) {
86
86
+
if (
87
87
+
hostname === "margin.at" ||
88
88
+
hostname.endsWith(".margin.at") ||
89
89
+
hostname === "semble.so" ||
90
90
+
hostname.endsWith(".semble.so")
91
91
+
) {
92
92
+
window.open(url, "_blank", "noopener,noreferrer");
93
93
+
return;
94
94
+
}
86
95
const skipped = $preferences.get().externalLinkSkippedHostnames;
87
96
if (skipped.includes(hostname)) {
88
97
window.open(url, "_blank", "noopener,noreferrer");
···
101
110
102
111
const timestamp = item.createdAt
103
112
? formatDistanceToNow(new Date(item.createdAt), { addSuffix: false })
113
113
+
.replace("less than a minute", "just now")
104
114
.replace("about ", "")
105
115
.replace(" hours", "h")
106
116
.replace(" hour", "h")
···
229
239
<span className="text-surface-400 dark:text-surface-500 text-sm">
230
240
{timestamp}
231
241
</span>
232
232
-
{isSemble && (
233
233
-
<span className="inline-flex items-center gap-1 text-[10px] text-surface-400 dark:text-surface-500 uppercase font-medium tracking-wide">
234
234
-
· via{" "}
235
235
-
<img
236
236
-
src="/semble-logo.svg"
237
237
-
alt="Semble"
238
238
-
className="h-3 opacity-70"
239
239
-
/>
240
240
-
</span>
241
241
-
)}
242
242
+
{isSemble &&
243
243
+
(() => {
244
244
+
const uri = item.uri || "";
245
245
+
const parts = uri.replace("at://", "").split("/");
246
246
+
const userHandle = item.author?.handle || parts[0] || "";
247
247
+
const rkey = parts[2] || "";
248
248
+
const targetUrl = item.target?.source || item.source || "";
249
249
+
let sembleUrl = `https://semble.so/profile/${userHandle}`;
250
250
+
if (uri.includes("network.cosmik.collection"))
251
251
+
sembleUrl = `https://semble.so/profile/${userHandle}/collections/${rkey}`;
252
252
+
else if (uri.includes("network.cosmik.card") && targetUrl)
253
253
+
sembleUrl = `https://semble.so/url?id=${encodeURIComponent(targetUrl)}`;
254
254
+
return (
255
255
+
<span className="relative inline-flex items-center">
256
256
+
<span className="text-surface-300 dark:text-surface-600">
257
257
+
·
258
258
+
</span>
259
259
+
<button
260
260
+
onClick={(e) => handleExternalClick(e, sembleUrl)}
261
261
+
className="group/semble relative inline-flex items-center ml-1 cursor-pointer"
262
262
+
>
263
263
+
<img
264
264
+
src="/semble-logo.svg"
265
265
+
alt="Semble"
266
266
+
className="h-3.5"
267
267
+
/>
268
268
+
<span className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2.5 py-1 rounded-lg bg-surface-800 dark:bg-surface-700 text-white text-[11px] font-medium whitespace-nowrap opacity-0 group-hover/semble:opacity-100 transition-opacity shadow-lg">
269
269
+
Open in Semble
270
270
+
<span className="absolute top-full left-1/2 -translate-x-1/2 -mt-px border-4 border-transparent border-t-surface-800 dark:border-t-surface-700" />
271
271
+
</span>
272
272
+
</button>
273
273
+
</span>
274
274
+
);
275
275
+
})()}
242
276
</div>
243
277
244
278
{pageUrl && !isBookmark && (
+50
-36
web/src/components/modals/ExternalLinkModal.tsx
···
1
1
import React, { useState } from "react";
2
2
import { Button } from "../ui";
3
3
-
import { ExternalLink, AlertTriangle } from "lucide-react";
3
3
+
import { ExternalLink, Shield } from "lucide-react";
4
4
import { addSkippedHostname } from "../../store/preferences";
5
5
6
6
interface ExternalLinkModalProps {
···
42
42
})();
43
43
44
44
return (
45
45
-
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-fade-in">
46
46
-
<div className="bg-white dark:bg-surface-900 rounded-2xl shadow-2xl max-w-sm w-full animate-scale-in ring-1 ring-black/5 dark:ring-white/10 p-6">
47
47
-
<div className="flex flex-col items-center text-center">
48
48
-
<div className="w-12 h-12 bg-yellow-100 dark:bg-yellow-900/30 text-yellow-600 dark:text-yellow-400 rounded-full flex items-center justify-center mb-4">
49
49
-
<AlertTriangle size={24} />
45
45
+
<div
46
46
+
className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm animate-fade-in"
47
47
+
onClick={onClose}
48
48
+
>
49
49
+
<div
50
50
+
className="bg-white dark:bg-surface-900 rounded-xl shadow-2xl max-w-md w-full animate-scale-in ring-1 ring-surface-200 dark:ring-surface-700 overflow-hidden"
51
51
+
onClick={(e) => e.stopPropagation()}
52
52
+
>
53
53
+
<div className="px-6 pt-6 pb-4">
54
54
+
<div className="flex items-start gap-3">
55
55
+
<div className="w-9 h-9 bg-primary-100 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5">
56
56
+
<Shield size={18} />
57
57
+
</div>
58
58
+
<div className="min-w-0">
59
59
+
<h2 className="text-base font-semibold text-surface-900 dark:text-white">
60
60
+
Leaving Margin
61
61
+
</h2>
62
62
+
<p className="text-sm text-surface-500 dark:text-surface-400 mt-1">
63
63
+
You're about to visit an external site.
64
64
+
</p>
65
65
+
</div>
50
66
</div>
51
67
52
52
-
<h2 className="text-xl font-bold text-surface-900 dark:text-white mb-2">
53
53
-
You are leaving Margin
54
54
-
</h2>
55
55
-
56
56
-
<p className="text-surface-500 dark:text-surface-400 text-sm mb-6 leading-relaxed">
57
57
-
This link will take you to an external website:
58
58
-
<br />
59
59
-
<span className="font-medium text-sm bg-surface-100 dark:bg-surface-800 text-surface-900 dark:text-surface-100 p-3 rounded-xl mt-3 block break-all border border-surface-200 dark:border-surface-700 shadow-sm">
68
68
+
<div className="mt-4 flex items-center gap-2 bg-surface-50 dark:bg-surface-800/60 border border-surface-200 dark:border-surface-700 rounded-lg px-3 py-2.5">
69
69
+
<ExternalLink
70
70
+
size={14}
71
71
+
className="text-surface-400 dark:text-surface-500 flex-shrink-0"
72
72
+
/>
73
73
+
<span className="text-sm text-surface-700 dark:text-surface-300 break-all line-clamp-2">
60
74
{displayUrl}
61
75
</span>
62
62
-
</p>
76
76
+
</div>
77
77
+
</div>
63
78
64
64
-
<div className="flex items-center gap-2 mb-6 w-full px-1">
79
79
+
<div className="px-6 pb-5 pt-2 flex flex-col gap-3">
80
80
+
<label className="flex items-center gap-2 cursor-pointer select-none group">
65
81
<input
66
82
type="checkbox"
67
67
-
id="dontAskAgain"
68
83
checked={dontAskAgain}
69
84
onChange={(e) => setDontAskAgain(e.target.checked)}
70
70
-
className="rounded border-surface-300 text-primary-600 focus:ring-primary-500 w-4 h-4 cursor-pointer"
85
85
+
className="rounded border-surface-300 dark:border-surface-600 text-primary-600 focus:ring-primary-500 w-3.5 h-3.5 cursor-pointer"
71
86
/>
72
72
-
<label
73
73
-
htmlFor="dontAskAgain"
74
74
-
className="text-sm text-surface-600 dark:text-surface-300 cursor-pointer select-none"
75
75
-
>
76
76
-
Don't ask again for{" "}
77
77
-
<span className="font-medium">{hostname}</span>
78
78
-
</label>
79
79
-
</div>
87
87
+
<span className="text-xs text-surface-500 dark:text-surface-400 group-hover:text-surface-600 dark:group-hover:text-surface-300 transition-colors">
88
88
+
Always allow links to{" "}
89
89
+
<span className="font-medium text-surface-700 dark:text-surface-200">
90
90
+
{hostname}
91
91
+
</span>
92
92
+
</span>
93
93
+
</label>
80
94
81
81
-
<div className="flex flex-col gap-3 w-full">
95
95
+
<div className="flex gap-2">
82
96
<Button
83
83
-
onClick={handleContinue}
84
84
-
variant="primary"
85
85
-
className="w-full justify-center"
86
86
-
icon={<ExternalLink size={16} />}
97
97
+
onClick={onClose}
98
98
+
variant="ghost"
99
99
+
className="flex-1 justify-center"
87
100
>
88
88
-
Continue to Site
101
101
+
Cancel
89
102
</Button>
90
103
<Button
91
91
-
onClick={onClose}
92
92
-
variant="ghost"
93
93
-
className="w-full justify-center"
104
104
+
onClick={handleContinue}
105
105
+
variant="primary"
106
106
+
className="flex-1 justify-center"
107
107
+
icon={<ExternalLink size={14} />}
94
108
>
95
95
-
Go Back
109
109
+
Open Link
96
110
</Button>
97
111
</div>
98
112
</div>
+1
-1
web/src/views/content/AnnotationDetail.tsx
···
261
261
value={replyText}
262
262
onChange={(e) => setReplyText(e.target.value)}
263
263
placeholder="Write a reply..."
264
264
-
className="w-full p-0 border-0 focus:ring-0 text-surface-900 dark:text-white placeholder:text-surface-400 dark:placeholder:text-surface-500 resize-none min-h-[40px] appearance-none bg-transparent leading-relaxed"
264
264
+
className="w-full p-3 bg-surface-50 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-lg text-surface-900 dark:text-white placeholder:text-surface-400 dark:placeholder:text-surface-500 focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500 dark:focus:border-primary-400 outline-none resize-none min-h-[80px]"
265
265
rows={2}
266
266
disabled={posting}
267
267
/>