(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
99
fork

Configure Feed

Select the types of activity you want to include in your feed.

lint

scanash00 4a171bb3 92ad1ff3

+96 -102
+3 -2
web/src/api/client.ts
··· 7 7 NotificationItem, 8 8 Target, 9 9 Selector, 10 + HydratedLabel, 10 11 } from "../types"; 11 12 export type { Collection } from "../types"; 12 13 ··· 1130 1131 if (!res.ok) return false; 1131 1132 const data = await res.json(); 1132 1133 return data.isAdmin || false; 1133 - } catch (e) { 1134 + } catch { 1134 1135 return false; 1135 1136 } 1136 1137 } ··· 1209 1210 export async function adminGetLabels( 1210 1211 limit = 50, 1211 1212 offset = 0, 1212 - ): Promise<{ items: any[] }> { 1213 + ): Promise<{ items: HydratedLabel[] }> { 1213 1214 try { 1214 1215 const res = await apiRequest( 1215 1216 `/api/moderation/admin/labels?limit=${limit}&offset=${offset}`,
+41 -42
web/src/components/common/Card.tsx
··· 121 121 const [showReportModal, setShowReportModal] = useState(false); 122 122 const [showEditModal, setShowEditModal] = useState(false); 123 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); 124 132 125 133 const contentWarning = getContentWarning(item.labels, preferences); 126 - 127 - if (contentWarning?.visibility === "hide") return null; 128 134 129 135 React.useEffect(() => { 130 136 setItem(initialItem); ··· 145 151 const isSemble = 146 152 item.uri?.includes("network.cosmik") || item.uri?.includes("semble"); 147 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 + 148 187 const handleLike = async () => { 149 188 const prev = { liked, likes }; 150 189 setLiked(!liked); ··· 213 252 214 253 const detailUrl = `/${item.author?.handle || item.author?.did}/${type}/${(item.uri || "").split("/").pop()}`; 215 254 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 255 const pageTitle = 227 256 item.target?.title || 228 257 item.title || ··· 236 265 return clean.length > 60 ? clean.slice(0, 57) + "..." : clean; 237 266 })() 238 267 : 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 268 270 269 const displayTitle = 271 270 item.title || ogData?.title || pageTitle || "Untitled Bookmark";
+19 -23
web/src/components/modals/EditItemModal.tsx
··· 1 - import React, { useState, useEffect } from "react"; 1 + import React, { useState } from "react"; 2 2 import { X, ShieldAlert } from "lucide-react"; 3 3 import { 4 4 updateAnnotation, ··· 38 38 type, 39 39 onSaved, 40 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">) { 41 59 const [text, setText] = useState(item.body?.value || ""); 42 60 const [tags, setTags] = useState<string[]>(item.tags || []); 43 61 const [tagInput, setTagInput] = useState(""); 44 - 45 62 const [color, setColor] = useState(item.color || "yellow"); 46 - 47 63 const [title, setTitle] = useState(item.title || item.target?.title || ""); 48 64 const [description, setDescription] = useState(item.description || ""); 49 - 50 65 const existingLabels = (item.labels || []) 51 66 .filter((l) => l.src === item.author?.did) 52 67 .map((l) => l.val as ContentLabelValue); ··· 55 70 const [showLabelPicker, setShowLabelPicker] = useState( 56 71 existingLabels.length > 0, 57 72 ); 58 - 59 73 const [saving, setSaving] = useState(false); 60 74 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 75 80 76 const addTag = () => { 81 77 const t = tagInput.trim().toLowerCase();
+20
web/src/types.ts
··· 214 214 name: string; 215 215 labels: LabelDefinition[]; 216 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 9 adminDeleteLabel, 10 10 adminGetLabels, 11 11 } from "../../api/client"; 12 - import type { ModerationReport } from "../../types"; 12 + import type { ModerationReport, HydratedLabel } from "../../types"; 13 13 import { 14 14 Shield, 15 15 CheckCircle, ··· 57 57 { val: "misleading", label: "Misleading" }, 58 58 ]; 59 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 60 type Tab = "reports" | "labels" | "actions"; 81 61 82 62 export default function AdminModeration() { ··· 100 80 const [labelSubmitting, setLabelSubmitting] = useState(false); 101 81 const [labelSuccess, setLabelSuccess] = useState(false); 102 82 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 83 const loadReports = async (status: string) => { 114 84 const data = await getAdminReports(status || undefined); 115 85 setReports(data.items); ··· 121 91 const data = await adminGetLabels(); 122 92 setLabels(data.items || []); 123 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 + }, []); 124 104 125 105 const handleTabChange = async (tab: Tab) => { 126 106 setActiveTab(tab);
+2 -4
web/src/views/profile/Profile.tsx
··· 7 7 unblockUser, 8 8 muteUser, 9 9 unmuteUser, 10 + getModerationRelationship, 10 11 } from "../../api/client"; 11 12 import Card from "../../components/common/Card"; 12 13 import RichText from "../../components/common/RichText"; ··· 38 39 Collection, 39 40 ModerationRelationship, 40 41 ContentLabel, 41 - LabelVisibility, 42 42 } from "../../types"; 43 43 import { useStore } from "@nanostores/react"; 44 44 import { $user } from "../../store/auth"; ··· 159 159 160 160 if (user && user.did !== did) { 161 161 try { 162 - const { getModerationRelationship } = 163 - await import("../../api/client"); 164 162 const rel = await getModerationRelationship(did); 165 163 setModRelation(rel); 166 164 } catch { ··· 174 172 } 175 173 }; 176 174 if (did) loadProfile(); 177 - }, [did]); 175 + }, [did, user]); 178 176 179 177 useEffect(() => { 180 178 loadPreferences();