tangled
alpha
login
or
join now
margin.at
/
margin
89
fork
atom
Write on the margins of the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
89
fork
atom
overview
issues
4
pulls
1
pipelines
lint
scanash.com
1 month ago
4a171bb3
92ad1ff3
+96
-102
6 changed files
expand all
collapse all
unified
split
web
src
api
client.ts
components
common
Card.tsx
modals
EditItemModal.tsx
types.ts
views
core
AdminModeration.tsx
profile
Profile.tsx
+3
-2
web/src/api/client.ts
···
7
NotificationItem,
8
Target,
9
Selector,
0
10
} from "../types";
11
export type { Collection } from "../types";
12
···
1130
if (!res.ok) return false;
1131
const data = await res.json();
1132
return data.isAdmin || false;
1133
-
} catch (e) {
1134
return false;
1135
}
1136
}
···
1209
export async function adminGetLabels(
1210
limit = 50,
1211
offset = 0,
1212
-
): Promise<{ items: any[] }> {
1213
try {
1214
const res = await apiRequest(
1215
`/api/moderation/admin/labels?limit=${limit}&offset=${offset}`,
···
7
NotificationItem,
8
Target,
9
Selector,
10
+
HydratedLabel,
11
} from "../types";
12
export type { Collection } from "../types";
13
···
1131
if (!res.ok) return false;
1132
const data = await res.json();
1133
return data.isAdmin || false;
1134
+
} catch {
1135
return false;
1136
}
1137
}
···
1210
export async function adminGetLabels(
1211
limit = 50,
1212
offset = 0,
1213
+
): Promise<{ items: HydratedLabel[] }> {
1214
try {
1215
const res = await apiRequest(
1216
`/api/moderation/admin/labels?limit=${limit}&offset=${offset}`,
+41
-42
web/src/components/common/Card.tsx
···
121
const [showReportModal, setShowReportModal] = useState(false);
122
const [showEditModal, setShowEditModal] = useState(false);
123
const [contentRevealed, setContentRevealed] = useState(false);
0
0
0
0
0
0
0
0
124
125
const contentWarning = getContentWarning(item.labels, preferences);
126
-
127
-
if (contentWarning?.visibility === "hide") return null;
128
129
React.useEffect(() => {
130
setItem(initialItem);
···
145
const isSemble =
146
item.uri?.includes("network.cosmik") || item.uri?.includes("semble");
147
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
148
const handleLike = async () => {
149
const prev = { liked, likes };
150
setLiked(!liked);
···
213
214
const detailUrl = `/${item.author?.handle || item.author?.did}/${type}/${(item.uri || "").split("/").pop()}`;
215
216
-
const safeUrlHostname = (url: string | null | undefined) => {
217
-
if (!url) return null;
218
-
try {
219
-
return new URL(url).hostname;
220
-
} catch {
221
-
return null;
222
-
}
223
-
};
224
-
225
-
const pageUrl = item.target?.source || item.source;
226
const pageTitle =
227
item.target?.title ||
228
item.title ||
···
236
return clean.length > 60 ? clean.slice(0, 57) + "..." : clean;
237
})()
238
: null;
239
-
const isBookmark = type === "bookmark";
240
-
241
-
const [ogData, setOgData] = useState<{
242
-
title?: string;
243
-
description?: string;
244
-
image?: string;
245
-
icon?: string;
246
-
} | null>(null);
247
-
248
-
const [imgError, setImgError] = useState(false);
249
-
const [iconError, setIconError] = useState(false);
250
-
251
-
React.useEffect(() => {
252
-
if (isBookmark && item.uri && !ogData && pageUrl) {
253
-
const fetchMetadata = async () => {
254
-
try {
255
-
const res = await fetch(
256
-
`/api/url-metadata?url=${encodeURIComponent(pageUrl)}`,
257
-
);
258
-
if (res.ok) {
259
-
const data = await res.json();
260
-
setOgData(data);
261
-
}
262
-
} catch (e) {
263
-
console.error("Failed to fetch metadata", e);
264
-
}
265
-
};
266
-
fetchMetadata();
267
-
}
268
-
}, [isBookmark, item.uri, pageUrl, ogData]);
269
270
const displayTitle =
271
item.title || ogData?.title || pageTitle || "Untitled Bookmark";
···
121
const [showReportModal, setShowReportModal] = useState(false);
122
const [showEditModal, setShowEditModal] = useState(false);
123
const [contentRevealed, setContentRevealed] = useState(false);
124
+
const [ogData, setOgData] = useState<{
125
+
title?: string;
126
+
description?: string;
127
+
image?: string;
128
+
icon?: string;
129
+
} | null>(null);
130
+
const [imgError, setImgError] = useState(false);
131
+
const [iconError, setIconError] = useState(false);
132
133
const contentWarning = getContentWarning(item.labels, preferences);
0
0
134
135
React.useEffect(() => {
136
setItem(initialItem);
···
151
const isSemble =
152
item.uri?.includes("network.cosmik") || item.uri?.includes("semble");
153
154
+
const safeUrlHostname = (url: string | null | undefined) => {
155
+
if (!url) return null;
156
+
try {
157
+
return new URL(url).hostname;
158
+
} catch {
159
+
return null;
160
+
}
161
+
};
162
+
163
+
const pageUrl = item.target?.source || item.source;
164
+
const isBookmark = type === "bookmark";
165
+
166
+
React.useEffect(() => {
167
+
if (isBookmark && item.uri && !ogData && pageUrl) {
168
+
const fetchMetadata = async () => {
169
+
try {
170
+
const res = await fetch(
171
+
`/api/url-metadata?url=${encodeURIComponent(pageUrl)}`,
172
+
);
173
+
if (res.ok) {
174
+
const data = await res.json();
175
+
setOgData(data);
176
+
}
177
+
} catch (e) {
178
+
console.error("Failed to fetch metadata", e);
179
+
}
180
+
};
181
+
fetchMetadata();
182
+
}
183
+
}, [isBookmark, item.uri, pageUrl, ogData]);
184
+
185
+
if (contentWarning?.visibility === "hide") return null;
186
+
187
const handleLike = async () => {
188
const prev = { liked, likes };
189
setLiked(!liked);
···
252
253
const detailUrl = `/${item.author?.handle || item.author?.did}/${type}/${(item.uri || "").split("/").pop()}`;
254
0
0
0
0
0
0
0
0
0
0
255
const pageTitle =
256
item.target?.title ||
257
item.title ||
···
265
return clean.length > 60 ? clean.slice(0, 57) + "..." : clean;
266
})()
267
: null;
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
268
269
const displayTitle =
270
item.title || ogData?.title || pageTitle || "Untitled Bookmark";
+19
-23
web/src/components/modals/EditItemModal.tsx
···
1
-
import React, { useState, useEffect } from "react";
2
import { X, ShieldAlert } from "lucide-react";
3
import {
4
updateAnnotation,
···
38
type,
39
onSaved,
40
}: EditItemModalProps) {
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
41
const [text, setText] = useState(item.body?.value || "");
42
const [tags, setTags] = useState<string[]>(item.tags || []);
43
const [tagInput, setTagInput] = useState("");
44
-
45
const [color, setColor] = useState(item.color || "yellow");
46
-
47
const [title, setTitle] = useState(item.title || item.target?.title || "");
48
const [description, setDescription] = useState(item.description || "");
49
-
50
const existingLabels = (item.labels || [])
51
.filter((l) => l.src === item.author?.did)
52
.map((l) => l.val as ContentLabelValue);
···
55
const [showLabelPicker, setShowLabelPicker] = useState(
56
existingLabels.length > 0,
57
);
58
-
59
const [saving, setSaving] = useState(false);
60
const [error, setError] = useState<string | null>(null);
61
-
62
-
useEffect(() => {
63
-
if (isOpen) {
64
-
setText(item.body?.value || "");
65
-
setTags(item.tags || []);
66
-
setTagInput("");
67
-
setColor(item.color || "yellow");
68
-
setTitle(item.title || item.target?.title || "");
69
-
setDescription(item.description || "");
70
-
const labels = (item.labels || [])
71
-
.filter((l) => l.src === item.author?.did)
72
-
.map((l) => l.val as ContentLabelValue);
73
-
setSelfLabels(labels);
74
-
setShowLabelPicker(labels.length > 0);
75
-
}
76
-
}, [isOpen, item]);
77
-
78
-
if (!isOpen) return null;
79
80
const addTag = () => {
81
const t = tagInput.trim().toLowerCase();
···
1
+
import React, { useState } from "react";
2
import { X, ShieldAlert } from "lucide-react";
3
import {
4
updateAnnotation,
···
38
type,
39
onSaved,
40
}: EditItemModalProps) {
41
+
if (!isOpen) return null;
42
+
return (
43
+
<EditItemModalContent
44
+
key={item.uri || item.id || JSON.stringify(item)}
45
+
item={item}
46
+
type={type}
47
+
onClose={onClose}
48
+
onSaved={onSaved}
49
+
/>
50
+
);
51
+
}
52
+
53
+
function EditItemModalContent({
54
+
item,
55
+
type,
56
+
onClose,
57
+
onSaved,
58
+
}: Omit<EditItemModalProps, "isOpen">) {
59
const [text, setText] = useState(item.body?.value || "");
60
const [tags, setTags] = useState<string[]>(item.tags || []);
61
const [tagInput, setTagInput] = useState("");
0
62
const [color, setColor] = useState(item.color || "yellow");
0
63
const [title, setTitle] = useState(item.title || item.target?.title || "");
64
const [description, setDescription] = useState(item.description || "");
0
65
const existingLabels = (item.labels || [])
66
.filter((l) => l.src === item.author?.did)
67
.map((l) => l.val as ContentLabelValue);
···
70
const [showLabelPicker, setShowLabelPicker] = useState(
71
existingLabels.length > 0,
72
);
0
73
const [saving, setSaving] = useState(false);
74
const [error, setError] = useState<string | null>(null);
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
75
76
const addTag = () => {
77
const t = tagInput.trim().toLowerCase();
+20
web/src/types.ts
···
214
name: string;
215
labels: LabelDefinition[];
216
}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
214
name: string;
215
labels: LabelDefinition[];
216
}
217
+
218
+
export interface HydratedLabel {
219
+
id: number;
220
+
src: string;
221
+
uri: string;
222
+
val: string;
223
+
createdBy: {
224
+
did: string;
225
+
handle: string;
226
+
displayName?: string;
227
+
avatar?: string;
228
+
};
229
+
createdAt: string;
230
+
subject?: {
231
+
did: string;
232
+
handle: string;
233
+
displayName?: string;
234
+
avatar?: string;
235
+
};
236
+
}
+11
-31
web/src/views/core/AdminModeration.tsx
···
9
adminDeleteLabel,
10
adminGetLabels,
11
} from "../../api/client";
12
-
import type { ModerationReport } from "../../types";
13
import {
14
Shield,
15
CheckCircle,
···
57
{ val: "misleading", label: "Misleading" },
58
];
59
60
-
interface HydratedLabel {
61
-
id: number;
62
-
src: string;
63
-
uri: string;
64
-
val: string;
65
-
createdBy: {
66
-
did: string;
67
-
handle: string;
68
-
displayName?: string;
69
-
avatar?: string;
70
-
};
71
-
createdAt: string;
72
-
subject?: {
73
-
did: string;
74
-
handle: string;
75
-
displayName?: string;
76
-
avatar?: string;
77
-
};
78
-
}
79
-
80
type Tab = "reports" | "labels" | "actions";
81
82
export default function AdminModeration() {
···
100
const [labelSubmitting, setLabelSubmitting] = useState(false);
101
const [labelSuccess, setLabelSuccess] = useState(false);
102
103
-
useEffect(() => {
104
-
const init = async () => {
105
-
const admin = await checkAdminAccess();
106
-
setIsAdmin(admin);
107
-
if (admin) await loadReports("pending");
108
-
setLoading(false);
109
-
};
110
-
init();
111
-
}, []);
112
-
113
const loadReports = async (status: string) => {
114
const data = await getAdminReports(status || undefined);
115
setReports(data.items);
···
121
const data = await adminGetLabels();
122
setLabels(data.items || []);
123
};
0
0
0
0
0
0
0
0
0
0
124
125
const handleTabChange = async (tab: Tab) => {
126
setActiveTab(tab);
···
9
adminDeleteLabel,
10
adminGetLabels,
11
} from "../../api/client";
12
+
import type { ModerationReport, HydratedLabel } from "../../types";
13
import {
14
Shield,
15
CheckCircle,
···
57
{ val: "misleading", label: "Misleading" },
58
];
59
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
60
type Tab = "reports" | "labels" | "actions";
61
62
export default function AdminModeration() {
···
80
const [labelSubmitting, setLabelSubmitting] = useState(false);
81
const [labelSuccess, setLabelSuccess] = useState(false);
82
0
0
0
0
0
0
0
0
0
0
83
const loadReports = async (status: string) => {
84
const data = await getAdminReports(status || undefined);
85
setReports(data.items);
···
91
const data = await adminGetLabels();
92
setLabels(data.items || []);
93
};
94
+
95
+
useEffect(() => {
96
+
const init = async () => {
97
+
const admin = await checkAdminAccess();
98
+
setIsAdmin(admin);
99
+
if (admin) await loadReports("pending");
100
+
setLoading(false);
101
+
};
102
+
init();
103
+
}, []);
104
105
const handleTabChange = async (tab: Tab) => {
106
setActiveTab(tab);
+2
-4
web/src/views/profile/Profile.tsx
···
7
unblockUser,
8
muteUser,
9
unmuteUser,
0
10
} from "../../api/client";
11
import Card from "../../components/common/Card";
12
import RichText from "../../components/common/RichText";
···
38
Collection,
39
ModerationRelationship,
40
ContentLabel,
41
-
LabelVisibility,
42
} from "../../types";
43
import { useStore } from "@nanostores/react";
44
import { $user } from "../../store/auth";
···
159
160
if (user && user.did !== did) {
161
try {
162
-
const { getModerationRelationship } =
163
-
await import("../../api/client");
164
const rel = await getModerationRelationship(did);
165
setModRelation(rel);
166
} catch {
···
174
}
175
};
176
if (did) loadProfile();
177
-
}, [did]);
178
179
useEffect(() => {
180
loadPreferences();
···
7
unblockUser,
8
muteUser,
9
unmuteUser,
10
+
getModerationRelationship,
11
} from "../../api/client";
12
import Card from "../../components/common/Card";
13
import RichText from "../../components/common/RichText";
···
39
Collection,
40
ModerationRelationship,
41
ContentLabel,
0
42
} from "../../types";
43
import { useStore } from "@nanostores/react";
44
import { $user } from "../../store/auth";
···
159
160
if (user && user.did !== did) {
161
try {
0
0
162
const rel = await getModerationRelationship(did);
163
setModRelation(rel);
164
} catch {
···
172
}
173
};
174
if (did) loadProfile();
175
+
}, [did, user]);
176
177
useEffect(() => {
178
loadPreferences();