Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments

lint

+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();