(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.

better moderation

+1634 -1112
+3
backend/internal/api/handler.go
··· 251 251 r.Post("/moderation/admin/label", h.moderation.AdminCreateLabel) 252 252 r.Delete("/moderation/admin/label", h.moderation.AdminDeleteLabel) 253 253 r.Get("/moderation/admin/labels", h.moderation.AdminGetLabels) 254 + r.Post("/moderation/admin/ban", h.moderation.AdminBanAccount) 255 + r.Delete("/moderation/admin/ban", h.moderation.AdminUnbanAccount) 256 + r.Get("/moderation/admin/bans", h.moderation.AdminGetBannedAccounts) 254 257 r.Get("/moderation/labeler", h.moderation.GetLabelerInfo) 255 258 256 259 // Admin
+103
backend/internal/api/moderation.go
··· 456 456 } 457 457 458 458 func (m *ModerationHandler) deleteContent(uri string) { 459 + m.db.MarkTakenDown(uri) 460 + m.db.Exec("DELETE FROM notes WHERE uri = $1", uri) 459 461 m.db.Exec("DELETE FROM annotations WHERE uri = $1", uri) 460 462 m.db.Exec("DELETE FROM highlights WHERE uri = $1", uri) 461 463 m.db.Exec("DELETE FROM bookmarks WHERE uri = $1", uri) ··· 654 656 "labels": labels, 655 657 }) 656 658 } 659 + 660 + func (m *ModerationHandler) AdminBanAccount(w http.ResponseWriter, r *http.Request) { 661 + session, err := m.refresher.GetSessionWithAutoRefresh(r) 662 + if err != nil { 663 + WriteUnauthorized(w, "Unauthorized") 664 + return 665 + } 666 + if !config.Get().IsAdmin(session.DID) { 667 + WriteForbidden(w, "Forbidden") 668 + return 669 + } 670 + 671 + var req struct { 672 + DID string `json:"did"` 673 + Reason *string `json:"reason,omitempty"` 674 + } 675 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.DID == "" { 676 + WriteBadRequest(w, "did is required") 677 + return 678 + } 679 + 680 + if err := m.db.BanAccount(req.DID, session.DID, req.Reason); err != nil { 681 + logger.Error("Failed to ban account: %v", err) 682 + WriteInternalError(w, "Failed to ban account") 683 + return 684 + } 685 + 686 + m.db.DeleteSessionsByDID(req.DID) 687 + 688 + WriteSuccess(w, map[string]string{"status": "ok"}) 689 + } 690 + 691 + func (m *ModerationHandler) AdminUnbanAccount(w http.ResponseWriter, r *http.Request) { 692 + session, err := m.refresher.GetSessionWithAutoRefresh(r) 693 + if err != nil { 694 + WriteUnauthorized(w, "Unauthorized") 695 + return 696 + } 697 + if !config.Get().IsAdmin(session.DID) { 698 + WriteForbidden(w, "Forbidden") 699 + return 700 + } 701 + 702 + did := r.URL.Query().Get("did") 703 + if did == "" { 704 + WriteBadRequest(w, "did is required") 705 + return 706 + } 707 + 708 + if err := m.db.UnbanAccount(did); err != nil { 709 + logger.Error("Failed to unban account: %v", err) 710 + WriteInternalError(w, "Failed to unban account") 711 + return 712 + } 713 + 714 + WriteSuccess(w, map[string]string{"status": "ok"}) 715 + } 716 + 717 + func (m *ModerationHandler) AdminGetBannedAccounts(w http.ResponseWriter, r *http.Request) { 718 + session, err := m.refresher.GetSessionWithAutoRefresh(r) 719 + if err != nil { 720 + WriteUnauthorized(w, "Unauthorized") 721 + return 722 + } 723 + if !config.Get().IsAdmin(session.DID) { 724 + WriteForbidden(w, "Forbidden") 725 + return 726 + } 727 + 728 + accounts, err := m.db.GetBannedAccounts() 729 + if err != nil { 730 + WriteInternalError(w, "Failed to get banned accounts") 731 + return 732 + } 733 + 734 + if accounts == nil { 735 + accounts = []db.BannedAccount{} 736 + } 737 + 738 + dids := make([]string, len(accounts)) 739 + for i, a := range accounts { 740 + dids[i] = a.DID 741 + } 742 + profileMap := fetchProfilesForDIDs(m.db, dids) 743 + 744 + type BannedEntry struct { 745 + db.BannedAccount 746 + Profile *Author `json:"profile,omitempty"` 747 + } 748 + entries := make([]BannedEntry, len(accounts)) 749 + for i, a := range accounts { 750 + p, ok := profileMap[a.DID] 751 + var profile *Author 752 + if ok { 753 + profile = &p 754 + } 755 + entries[i] = BannedEntry{BannedAccount: a, Profile: profile} 756 + } 757 + 758 + WriteSuccess(w, map[string]interface{}{"items": entries, "total": len(entries)}) 759 + }
+10
backend/internal/db/migrations/00007_banned_accounts.sql
··· 1 + -- +goose Up 2 + CREATE TABLE IF NOT EXISTS banned_accounts ( 3 + did TEXT PRIMARY KEY, 4 + reason TEXT, 5 + banned_by TEXT NOT NULL, 6 + banned_at TIMESTAMP NOT NULL DEFAULT NOW() 7 + ); 8 + 9 + -- +goose Down 10 + DROP TABLE IF EXISTS banned_accounts;
+8
backend/internal/db/migrations/00008_taken_down_uris.sql
··· 1 + -- +goose Up 2 + CREATE TABLE IF NOT EXISTS taken_down_uris ( 3 + uri TEXT PRIMARY KEY, 4 + taken_down_at TIMESTAMP NOT NULL DEFAULT NOW() 5 + ); 6 + 7 + -- +goose Down 8 + DROP TABLE IF EXISTS taken_down_uris;
+3
backend/internal/db/queries_annotations.go
··· 5 5 ) 6 6 7 7 func (db *DB) CreateAnnotation(a *Annotation) error { 8 + if taken, _ := db.IsTakenDown(a.URI); taken { 9 + return nil 10 + } 8 11 _, err := db.Exec(` 9 12 INSERT INTO annotations (uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid) 10 13 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
+3
backend/internal/db/queries_highlights.go
··· 5 5 ) 6 6 7 7 func (db *DB) CreateHighlight(h *Highlight) error { 8 + if taken, _ := db.IsTakenDown(h.URI); taken { 9 + return nil 10 + } 8 11 _, err := db.Exec(` 9 12 INSERT INTO highlights (uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid) 10 13 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
+77
backend/internal/db/queries_moderation.go
··· 400 400 func itoa(i int) string { 401 401 return strings.Repeat("", 0) + fmt.Sprintf("%d", i) 402 402 } 403 + 404 + func (db *DB) MarkTakenDown(uri string) error { 405 + _, err := db.Exec(` 406 + INSERT INTO taken_down_uris (uri, taken_down_at) VALUES ($1, $2) 407 + ON CONFLICT(uri) DO NOTHING 408 + `, uri, time.Now()) 409 + return err 410 + } 411 + 412 + func (db *DB) IsTakenDown(uri string) (bool, error) { 413 + var exists bool 414 + err := db.QueryRow(`SELECT EXISTS(SELECT 1 FROM taken_down_uris WHERE uri = $1)`, uri).Scan(&exists) 415 + return exists, err 416 + } 417 + 418 + type BannedAccount struct { 419 + DID string `json:"did"` 420 + Reason *string `json:"reason,omitempty"` 421 + BannedBy string `json:"bannedBy"` 422 + BannedAt time.Time `json:"bannedAt"` 423 + } 424 + 425 + func (db *DB) BanAccount(did, bannedBy string, reason *string) error { 426 + _, err := db.Exec(` 427 + INSERT INTO banned_accounts (did, reason, banned_by, banned_at) 428 + VALUES ($1, $2, $3, $4) 429 + ON CONFLICT(did) DO UPDATE SET reason = EXCLUDED.reason, banned_by = EXCLUDED.banned_by, banned_at = EXCLUDED.banned_at 430 + `, did, reason, bannedBy, time.Now()) 431 + return err 432 + } 433 + 434 + func (db *DB) UnbanAccount(did string) error { 435 + _, err := db.Exec(`DELETE FROM banned_accounts WHERE did = $1`, did) 436 + return err 437 + } 438 + 439 + func (db *DB) IsBanned(did string) (bool, error) { 440 + var exists bool 441 + err := db.QueryRow(`SELECT EXISTS(SELECT 1 FROM banned_accounts WHERE did = $1)`, did).Scan(&exists) 442 + return exists, err 443 + } 444 + 445 + func (db *DB) GetBannedAccounts() ([]BannedAccount, error) { 446 + rows, err := db.Query(`SELECT did, reason, banned_by, banned_at FROM banned_accounts ORDER BY banned_at DESC`) 447 + if err != nil { 448 + return nil, err 449 + } 450 + defer rows.Close() 451 + 452 + var accounts []BannedAccount 453 + for rows.Next() { 454 + var a BannedAccount 455 + if err := rows.Scan(&a.DID, &a.Reason, &a.BannedBy, &a.BannedAt); err != nil { 456 + continue 457 + } 458 + accounts = append(accounts, a) 459 + } 460 + return accounts, nil 461 + } 462 + 463 + func (db *DB) GetBannedDIDs() ([]string, error) { 464 + rows, err := db.Query(`SELECT did FROM banned_accounts`) 465 + if err != nil { 466 + return nil, err 467 + } 468 + defer rows.Close() 469 + 470 + var dids []string 471 + for rows.Next() { 472 + var did string 473 + if err := rows.Scan(&did); err != nil { 474 + continue 475 + } 476 + dids = append(dids, did) 477 + } 478 + return dids, nil 479 + }
+16 -3
backend/internal/db/queries_sessions.go
··· 1 1 package db 2 2 3 3 import ( 4 + "errors" 4 5 "time" 5 6 ) 7 + 8 + var ErrAccountBanned = errors.New("account is banned") 6 9 7 10 func (db *DB) SaveSession(id, did, handle, accessToken, refreshToken, dpopKey string, expiresAt time.Time) error { 8 - _, err := db.Exec(` 11 + banned, err := db.IsBanned(did) 12 + if err == nil && banned { 13 + return ErrAccountBanned 14 + } 15 + 16 + _, err = db.Exec(` 9 17 INSERT INTO sessions (id, did, handle, access_token, refresh_token, dpop_key, created_at, expires_at) 10 18 VALUES ($1, $2, $3, $4, $5, $6, $7, $8) 11 19 ON CONFLICT(id) DO UPDATE SET ··· 36 44 return err 37 45 } 38 46 47 + func (db *DB) DeleteSessionsByDID(did string) error { 48 + _, err := db.Exec(`DELETE FROM sessions WHERE did = $1`, did) 49 + return err 50 + } 51 + 39 52 func (db *DB) CountSessionsByDID(did string) (int, error) { 40 53 var n int 41 54 err := db.QueryRow( 42 - `SELECT COUNT(*) FROM sessions WHERE did = $1 AND expires_at > $2`, 43 - did, time.Now(), 55 + `SELECT COUNT(*) FROM sessions WHERE did = $1`, 56 + did, 44 57 ).Scan(&n) 45 58 return n, err 46 59 }
+4
backend/internal/oauth/handler.go
··· 456 456 expiresAt, 457 457 ) 458 458 if err != nil { 459 + if err == db.ErrAccountBanned { 460 + http.Redirect(w, r, "/login?error=banned", http.StatusFound) 461 + return 462 + } 459 463 http.Error(w, "Failed to save session", http.StatusInternalServerError) 460 464 return 461 465 }
+16
backend/internal/service/feed.go
··· 27 27 hydration *HydrationService 28 28 database interface { 29 29 GetAllHiddenDIDs(actorDID string) (map[string]bool, error) 30 + GetBannedDIDs() ([]string, error) 30 31 } 31 32 } 32 33 ··· 35 36 hydration *HydrationService, 36 37 db interface { 37 38 GetAllHiddenDIDs(actorDID string) (map[string]bool, error) 39 + GetBannedDIDs() ([]string, error) 38 40 }, 39 41 ) *FeedService { 40 42 return &FeedService{ ··· 62 64 notes, err := s.notes.List(ctx, filter) 63 65 if err != nil { 64 66 return nil, err 67 + } 68 + 69 + if bannedDIDs, err := s.database.GetBannedDIDs(); err == nil && len(bannedDIDs) > 0 { 70 + banned := make(map[string]bool, len(bannedDIDs)) 71 + for _, did := range bannedDIDs { 72 + banned[did] = true 73 + } 74 + filtered := notes[:0] 75 + for _, n := range notes { 76 + if !banned[n.AuthorDID] { 77 + filtered = append(filtered, n) 78 + } 79 + } 80 + notes = filtered 65 81 } 66 82 67 83 if req.ViewerDID != "" {
+736 -732
web/public/locales/en/translation.json
··· 1 1 { 2 - "appTitle": "Margin", 3 - "nav": { 4 - "feed": "Feed", 5 - "discover": "Discover", 6 - "annotations": "Annotations", 7 - "highlights": "Highlights", 8 - "bookmarks": "Bookmarks", 9 - "collections": "Collections", 10 - "activity": "Activity", 11 - "settings": "Settings", 12 - "new": "New", 13 - "signIn": "Sign in", 14 - "logOut": "Log out", 15 - "themeLight": "Light", 16 - "themeDark": "Dark", 17 - "themeSystem": "System" 18 - }, 19 - "pageTitles": { 20 - "home": "Home — Margin", 21 - "bookmarks": "Bookmarks — Margin", 22 - "highlights": "Highlights — Margin", 23 - "annotations": "Annotations — Margin", 24 - "discover": "Discover — Margin", 25 - "search": "Search — Margin", 26 - "notifications": "Notifications — Margin", 27 - "new": "New Annotation — Margin", 28 - "settings": "Settings — Margin", 29 - "collections": "Collections — Margin", 30 - "admin": "Admin — Margin" 31 - }, 32 - "sidebar": { 33 - "getExtension": "Get the Extension", 34 - "extensionTagline": "Highlight, annotate, and bookmark from any page.", 35 - "downloadForFirefox": "Download for Firefox", 36 - "downloadForEdge": "Download for Edge", 37 - "downloadForChrome": "Download for Chrome", 38 - "trending": "Trending", 39 - "nothingTrending": "Nothing trending right now.", 40 - "searchPlaceholder": "Search people, tags, URLs…", 41 - "copyright": "© 2026 Padding Labs LLC", 42 - "postCount_one": "{{count}} post", 43 - "postCount_other": "{{count}} posts" 2 + "appTitle": "Margin", 3 + "nav": { 4 + "feed": "Feed", 5 + "discover": "Discover", 6 + "annotations": "Annotations", 7 + "highlights": "Highlights", 8 + "bookmarks": "Bookmarks", 9 + "collections": "Collections", 10 + "activity": "Activity", 11 + "settings": "Settings", 12 + "new": "New", 13 + "signIn": "Sign in", 14 + "logOut": "Log out", 15 + "themeLight": "Light", 16 + "themeDark": "Dark", 17 + "themeSystem": "System" 18 + }, 19 + "pageTitles": { 20 + "home": "Home — Margin", 21 + "bookmarks": "Bookmarks — Margin", 22 + "highlights": "Highlights — Margin", 23 + "annotations": "Annotations — Margin", 24 + "discover": "Discover — Margin", 25 + "search": "Search — Margin", 26 + "notifications": "Notifications — Margin", 27 + "new": "New Annotation — Margin", 28 + "settings": "Settings — Margin", 29 + "collections": "Collections — Margin", 30 + "admin": "Admin — Margin" 31 + }, 32 + "sidebar": { 33 + "getExtension": "Get the Extension", 34 + "extensionTagline": "Highlight, annotate, and bookmark from any page.", 35 + "downloadForFirefox": "Download for Firefox", 36 + "downloadForEdge": "Download for Edge", 37 + "downloadForChrome": "Download for Chrome", 38 + "trending": "Trending", 39 + "nothingTrending": "Nothing trending right now.", 40 + "searchPlaceholder": "Search people, tags, URLs…", 41 + "copyright": "© 2026 Padding Labs LLC", 42 + "postCount_one": "{{count}} post", 43 + "postCount_other": "{{count}} posts" 44 + }, 45 + "mobileNav": { 46 + "iosShortcut": "iOS Shortcut" 47 + }, 48 + "feed": { 49 + "welcome": "Welcome to Margin", 50 + "welcomeTagline": "A quiet place to annotate, highlight, and save what you read on the web.", 51 + "getStarted": "Get started", 52 + "learnMore": "Learn more", 53 + "tabs": { 54 + "recent": "Recent", 55 + "popular": "Popular", 56 + "shelved": "Shelved", 57 + "margin": "Margin", 58 + "semble": "Semble" 44 59 }, 45 - "mobileNav": { 46 - "iosShortcut": "iOS Shortcut" 60 + "filters": { 61 + "all": "All", 62 + "annotations": "Annotations", 63 + "highlights": "Highlights", 64 + "bookmarks": "Bookmarks" 47 65 }, 48 - "feed": { 49 - "welcome": "Welcome to Margin", 50 - "welcomeTagline": "A quiet place to annotate, highlight, and save what you read on the web.", 51 - "getStarted": "Get started", 52 - "learnMore": "Learn more", 53 - "tabs": { 54 - "recent": "Recent", 55 - "popular": "Popular", 56 - "shelved": "Shelved", 57 - "margin": "Margin", 58 - "semble": "Semble" 59 - }, 60 - "filters": { 61 - "all": "All", 62 - "annotations": "Annotations", 63 - "highlights": "Highlights", 64 - "bookmarks": "Bookmarks" 65 - }, 66 - "itemsWithTag": "Items with tag:", 67 - "clearFilter": "Clear filter", 68 - "everyone": "Everyone", 69 - "mine": "Mine", 70 - "defaultEmptyMessage": "Nothing here yet — annotations from you and people you follow will show up here.", 71 - "nothingHereYet": "Nothing here yet", 72 - "loading": "Loading…" 66 + "itemsWithTag": "Items with tag:", 67 + "clearFilter": "Clear filter", 68 + "everyone": "Everyone", 69 + "mine": "Mine", 70 + "defaultEmptyMessage": "Nothing here yet — annotations from you and people you follow will show up here.", 71 + "nothingHereYet": "Nothing here yet", 72 + "loading": "Loading…" 73 + }, 74 + "discover": { 75 + "tabs": { 76 + "new": "New", 77 + "popular": "Popular", 78 + "forYou": "For You" 73 79 }, 74 - "discover": { 75 - "tabs": { 76 - "new": "New", 77 - "popular": "Popular", 78 - "forYou": "For You" 79 - }, 80 - "comingSoon": "Coming soon", 81 - "forYouNotAvailable": "Personalized recommendations aren't available on this server yet.", 82 - "noDocumentsYet": "No documents have been discovered yet. Check back soon!", 83 - "startAnnotating": "Start annotating and highlighting to get personalized recommendations.", 84 - "loadMore": "Load more" 80 + "comingSoon": "Coming soon", 81 + "forYouNotAvailable": "Personalized recommendations aren't available on this server yet.", 82 + "noDocumentsYet": "No documents have been discovered yet. Check back soon!", 83 + "startAnnotating": "Start annotating and highlighting to get personalized recommendations.", 84 + "loadMore": "Load more" 85 + }, 86 + "search": { 87 + "placeholder": "Search annotations, highlights, bookmarks…", 88 + "noResults": "No results found", 89 + "noResultsMessage": "Nothing matched \"{{query}}\". Try different keywords.", 90 + "emptyTitle": "Search your library", 91 + "emptyMessage": "Find annotations, highlights, and bookmarks by keyword, URL, or tag.", 92 + "filters": { 93 + "all": "All", 94 + "annotations": "Annotations", 95 + "highlights": "Highlights", 96 + "bookmarks": "Bookmarks", 97 + "mine": "Mine" 85 98 }, 86 - "search": { 87 - "placeholder": "Search annotations, highlights, bookmarks…", 88 - "noResults": "No results found", 89 - "noResultsMessage": "Nothing matched \"{{query}}\". Try different keywords.", 90 - "emptyTitle": "Search your library", 91 - "emptyMessage": "Find annotations, highlights, and bookmarks by keyword, URL, or tag.", 92 - "filters": { 93 - "all": "All", 94 - "annotations": "Annotations", 95 - "highlights": "Highlights", 96 - "bookmarks": "Bookmarks", 97 - "mine": "Mine" 98 - }, 99 - "resultCount": "{{count}}{{hasMore}} results for \"{{query}}\"", 100 - "loadMore": "Load more" 101 - }, 102 - "notifications": { 103 - "title": "Activity", 104 - "noActivity": "No activity yet", 105 - "noActivityMessage": "Interactions with your content will appear here.", 106 - "likedAnnotation": "liked your annotation", 107 - "likedHighlight": "liked your highlight", 108 - "likedBookmark": "liked your bookmark", 109 - "likedReply": "liked your reply", 110 - "likedPost": "liked your post", 111 - "repliedToReply": "replied to your reply", 112 - "repliedToAnnotation": "replied to your annotation", 113 - "mentionedInAnnotation": "mentioned you in an annotation", 114 - "followedYou": "followed you", 115 - "highlightedPage": "highlighted your page", 116 - "inReplyTo": "in reply to", 117 - "aReply": "a reply", 118 - "anAnnotation": "an annotation" 119 - }, 120 - "collections": { 121 - "title": "Collections", 122 - "subtitle": "Organize your annotations and highlights", 123 - "none": "No collections yet", 124 - "noneMessage": "Create a collection to organize your highlights and annotations.", 125 - "createButton": "Create collection", 126 - "newTitle": "New Collection", 127 - "editTitle": "Edit Collection", 128 - "namePlaceholder": "My Collection", 129 - "namePlaceholderEdit": "Collection name", 130 - "nameLabel": "Name", 131 - "iconLabel": "Icon", 132 - "iconsTab": "Icons", 133 - "emojisTab": "Emojis", 134 - "selectedIcon": "Selected:", 135 - "descriptionLabel": "Description (optional)", 136 - "descriptionPlaceholder": "What's this collection for?", 137 - "descriptionPlaceholderEdit": "What's this collection about?", 138 - "cancel": "Cancel", 139 - "create": "Create Collection", 140 - "creating": "Creating…", 141 - "save": "Save Changes", 142 - "saving": "Saving…", 143 - "deleteConfirm": "Delete this collection?", 144 - "failedUpdate": "Failed to update collection", 145 - "errorUpdating": "An error occurred while updating", 146 - "itemCount_one": "{{count}} item", 147 - "itemCount_other": "{{count}} items" 148 - }, 149 - "collectionDetail": { 150 - "backLink": "Collections", 151 - "by": "by", 152 - "edit": "Edit collection", 153 - "delete": "Delete collection", 154 - "removeFromCollection": "Remove from collection", 155 - "viewInSemble": "View in Semble", 156 - "empty": "Collection is empty", 157 - "notFound": "Collection not found", 158 - "failedToLoad": "Failed to load collection", 159 - "deleteConfirm": "Delete this collection?", 160 - "removeConfirm": "Remove from collection?" 161 - }, 162 - "profile": { 163 - "notFound": "User not found", 164 - "notFoundMessage": "This profile doesn't exist or couldn't be loaded.", 165 - "edit": "Edit", 166 - "viewInBluesky": "View profile in Bluesky", 167 - "unblock": "Unblock @{{handle}}", 168 - "block": "Block @{{handle}}", 169 - "unmute": "Unmute @{{handle}}", 170 - "mute": "Mute @{{handle}}", 171 - "report": "Report", 172 - "accountLabeled": "Account labeled: {{description}}", 173 - "labelApplied": "This label was applied by a moderation service you subscribe to.", 174 - "show": "Show", 175 - "hide": "Hide", 176 - "blockedBanner": "You have blocked @{{handle}}", 177 - "blockedMessage": "Their content is hidden from your feeds.", 178 - "mutedBanner": "You have muted @{{handle}}", 179 - "mutedMessage": "Their content is hidden from your feeds.", 180 - "blockedByBanner": "@{{handle}} has blocked you. You cannot interact with their content.", 181 - "unblock_action": "Unblock", 182 - "unmute_action": "Unmute", 183 - "emptyCollectionsOwn": "You haven't created any collections yet.", 184 - "emptyCollectionsOther": "No collections", 185 - "itemCount_one": "{{count}} item", 186 - "itemCount_other": "{{count}} items", 187 - "emptyTabOwn": "Your {{tab}} will show up here.", 188 - "emptyTabOther": "Nothing to see here yet." 189 - }, 190 - "login": { 191 - "signInWith": "Sign in with your", 192 - "handleSuffix": "handle", 193 - "handlePlaceholder": "handle.margin.cafe", 194 - "connecting": "Connecting…", 195 - "continue": "Continue", 196 - "createAccount": "Create New Account", 197 - "termsPrefix": "By signing in, you agree to our", 198 - "termsLink": "Terms of Service", 199 - "termsAnd": "and", 200 - "privacyLink": "Privacy Policy" 201 - }, 202 - "signUp": { 203 - "title": "Create your account", 204 - "subtitle": "Margin adheres to the", 205 - "atProtocol": "AT Protocol", 206 - "subtitleSuffix": ". Choose a provider to host your account.", 207 - "customPdsTitle": "Use a custom PDS", 208 - "customPdsSubtitle": "Enter the address of the PDS hosting your account.", 209 - "pdsAddressLabel": "PDS address", 210 - "pdsAddressPlaceholder": "pds.example.com", 211 - "connecting": "Connecting…", 212 - "back": "Back", 213 - "continue": "Continue", 214 - "invite": "Invite", 215 - "providerError": "Could not connect to this provider. Please try again.", 216 - "customPdsError": "Couldn't connect to that PDS. Double-check the address.", 217 - "providers": { 218 - "margin": { 219 - "name": "Margin", 220 - "description": "The easiest way to get started" 221 - }, 222 - "bluesky": { 223 - "name": "Bluesky", 224 - "description": "The largest and most popular community" 225 - }, 226 - "blacksky": { 227 - "name": "Blacksky", 228 - "description": "For the Culture — a safe space for users and allies" 229 - }, 230 - "eurosky": { 231 - "name": "Eurosky", 232 - "description": "Eurosky is your European home on the Atmosphere" 233 - }, 234 - "selfhostedSocial": { 235 - "name": "selfhosted.social", 236 - "description": "A home for builders, tinkerers, and the curious" 237 - }, 238 - "northsky": { 239 - "name": "Northsky", 240 - "description": "A Canadian worker-owned cooperative" 241 - }, 242 - "tophhie": { 243 - "name": "Tophhie", 244 - "description": "A welcoming and friendly community" 245 - }, 246 - "customPds": { 247 - "name": "Use a custom PDS", 248 - "description": "Already have a PDS? Enter its address." 249 - } 250 - } 251 - }, 252 - "composer": { 253 - "newHighlight": "New highlight", 254 - "newAnnotation": "New annotation", 255 - "newNote": "New note", 256 - "saveHighlight": "Save highlight", 257 - "postAnnotation": "Post annotation", 258 - "postNote": "Post note", 259 - "highlightHint": "Saving a passage without a comment. Add text below to turn it into an annotation.", 260 - "addQuote": "+ Add a quote from the page", 261 - "quotePlaceholder": "Paste or type the text you're annotating…", 262 - "removeQuote": "Remove Quote", 263 - "thoughtsPlaceholder": "Add your thoughts on this passage…", 264 - "mindPlaceholder": "What's on your mind?", 265 - "tagsPlaceholder": "Add tags…", 266 - "contentWarning": "Content Warning", 267 - "contentWarningCount": "Content Warning ({{count}})", 268 - "cancel": "Cancel", 269 - "failedToPost": "Failed to post", 270 - "labels": { 271 - "sexual": "Sexual", 272 - "nudity": "Nudity", 273 - "violence": "Violence", 274 - "gore": "Gore", 275 - "spam": "Spam", 276 - "misleading": "Misleading" 277 - } 278 - }, 279 - "card": { 280 - "addedTo": "Added to", 281 - "addedToLower": "added to", 282 - "and": "and", 283 - "communityBookmark": "Community bookmark", 284 - "openInSemble": "Open in Semble", 285 - "deleteConfirm": "Delete this item?", 286 - "hideContent": "Hide Content", 287 - "show": "Show", 288 - "edited": "(edited)", 289 - "annotate": "Annotate", 290 - "untitledBookmark": "Untitled Bookmark", 291 - "addNotePlaceholder": "Add your note to convert this highlight into an annotation…", 292 - "addToCollectionTitle": "Add to Collection", 293 - "annotateTitle": "Annotate this highlight", 294 - "editTitle": "Edit", 295 - "deleteTitle": "Delete", 296 - "report": "Report", 297 - "muteUser": "Mute @{{handle}}", 298 - "blockUser": "Block @{{handle}}", 299 - "convertToAnnotation": "Convert to annotation", 300 - "justNow": "just now", 301 - "labelDescriptions": { 302 - "sexual": "Sexual Content", 303 - "nudity": "Nudity", 304 - "violence": "Violence", 305 - "gore": "Graphic Content", 306 - "spam": "Spam", 307 - "misleading": "Misleading" 308 - } 99 + "resultCount": "{{count}}{{hasMore}} results for \"{{query}}\"", 100 + "loadMore": "Load more" 101 + }, 102 + "notifications": { 103 + "title": "Activity", 104 + "noActivity": "No activity yet", 105 + "noActivityMessage": "Interactions with your content will appear here.", 106 + "likedAnnotation": "liked your annotation", 107 + "likedHighlight": "liked your highlight", 108 + "likedBookmark": "liked your bookmark", 109 + "likedReply": "liked your reply", 110 + "likedPost": "liked your post", 111 + "repliedToReply": "replied to your reply", 112 + "repliedToAnnotation": "replied to your annotation", 113 + "mentionedInAnnotation": "mentioned you in an annotation", 114 + "followedYou": "followed you", 115 + "highlightedPage": "highlighted your page", 116 + "inReplyTo": "in reply to", 117 + "aReply": "a reply", 118 + "anAnnotation": "an annotation" 119 + }, 120 + "collections": { 121 + "title": "Collections", 122 + "subtitle": "Organize your annotations and highlights", 123 + "none": "No collections yet", 124 + "noneMessage": "Create a collection to organize your highlights and annotations.", 125 + "createButton": "Create collection", 126 + "newTitle": "New Collection", 127 + "editTitle": "Edit Collection", 128 + "namePlaceholder": "My Collection", 129 + "namePlaceholderEdit": "Collection name", 130 + "nameLabel": "Name", 131 + "iconLabel": "Icon", 132 + "iconsTab": "Icons", 133 + "emojisTab": "Emojis", 134 + "selectedIcon": "Selected:", 135 + "descriptionLabel": "Description (optional)", 136 + "descriptionPlaceholder": "What's this collection for?", 137 + "descriptionPlaceholderEdit": "What's this collection about?", 138 + "cancel": "Cancel", 139 + "create": "Create Collection", 140 + "creating": "Creating…", 141 + "save": "Save Changes", 142 + "saving": "Saving…", 143 + "deleteConfirm": "Delete this collection?", 144 + "failedUpdate": "Failed to update collection", 145 + "errorUpdating": "An error occurred while updating", 146 + "itemCount_one": "{{count}} item", 147 + "itemCount_other": "{{count}} items" 148 + }, 149 + "collectionDetail": { 150 + "backLink": "Collections", 151 + "by": "by", 152 + "edit": "Edit collection", 153 + "delete": "Delete collection", 154 + "removeFromCollection": "Remove from collection", 155 + "viewInSemble": "View in Semble", 156 + "empty": "Collection is empty", 157 + "notFound": "Collection not found", 158 + "failedToLoad": "Failed to load collection", 159 + "deleteConfirm": "Delete this collection?", 160 + "removeConfirm": "Remove from collection?" 161 + }, 162 + "profile": { 163 + "notFound": "User not found", 164 + "notFoundMessage": "This profile doesn't exist or couldn't be loaded.", 165 + "edit": "Edit", 166 + "viewInBluesky": "View profile in Bluesky", 167 + "unblock": "Unblock @{{handle}}", 168 + "block": "Block @{{handle}}", 169 + "unmute": "Unmute @{{handle}}", 170 + "mute": "Mute @{{handle}}", 171 + "report": "Report", 172 + "accountLabeled": "Account labeled: {{description}}", 173 + "labelApplied": "This label was applied by a moderation service you subscribe to.", 174 + "show": "Show", 175 + "hide": "Hide", 176 + "blockedBanner": "You have blocked @{{handle}}", 177 + "blockedMessage": "Their content is hidden from your feeds.", 178 + "mutedBanner": "You have muted @{{handle}}", 179 + "mutedMessage": "Their content is hidden from your feeds.", 180 + "blockedByBanner": "@{{handle}} has blocked you. You cannot interact with their content.", 181 + "unblock_action": "Unblock", 182 + "unmute_action": "Unmute", 183 + "emptyCollectionsOwn": "You haven't created any collections yet.", 184 + "emptyCollectionsOther": "No collections", 185 + "itemCount_one": "{{count}} item", 186 + "itemCount_other": "{{count}} items", 187 + "emptyTabOwn": "Your {{tab}} will show up here.", 188 + "emptyTabOther": "Nothing to see here yet." 189 + }, 190 + "login": { 191 + "signInWith": "Sign in with your", 192 + "handleSuffix": "handle", 193 + "handlePlaceholder": "handle.margin.cafe", 194 + "connecting": "Connecting…", 195 + "continue": "Continue", 196 + "createAccount": "Create New Account", 197 + "termsPrefix": "By signing in, you agree to our", 198 + "termsLink": "Terms of Service", 199 + "termsAnd": "and", 200 + "privacyLink": "Privacy Policy", 201 + "bannedTitle": "Your account has been suspended", 202 + "bannedMessage": "This account has been suspended from Margin and is no longer able to sign in.", 203 + "bannedAppeal": "If you believe this is a mistake, you can appeal by emailing", 204 + "bannedSignOut": "Sign out" 205 + }, 206 + "signUp": { 207 + "title": "Create your account", 208 + "subtitle": "Margin adheres to the", 209 + "atProtocol": "AT Protocol", 210 + "subtitleSuffix": ". Choose a provider to host your account.", 211 + "customPdsTitle": "Use a custom PDS", 212 + "customPdsSubtitle": "Enter the address of the PDS hosting your account.", 213 + "pdsAddressLabel": "PDS address", 214 + "pdsAddressPlaceholder": "pds.example.com", 215 + "connecting": "Connecting…", 216 + "back": "Back", 217 + "continue": "Continue", 218 + "invite": "Invite", 219 + "providerError": "Could not connect to this provider. Please try again.", 220 + "customPdsError": "Couldn't connect to that PDS. Double-check the address.", 221 + "providers": { 222 + "margin": { 223 + "name": "Margin", 224 + "description": "The easiest way to get started" 225 + }, 226 + "bluesky": { 227 + "name": "Bluesky", 228 + "description": "The largest and most popular community" 229 + }, 230 + "blacksky": { 231 + "name": "Blacksky", 232 + "description": "For the Culture — a safe space for users and allies" 233 + }, 234 + "eurosky": { 235 + "name": "Eurosky", 236 + "description": "Eurosky is your European home on the Atmosphere" 237 + }, 238 + "selfhostedSocial": { 239 + "name": "selfhosted.social", 240 + "description": "A home for builders, tinkerers, and the curious" 241 + }, 242 + "northsky": { 243 + "name": "Northsky", 244 + "description": "A Canadian worker-owned cooperative" 245 + }, 246 + "tophhie": { 247 + "name": "Tophhie", 248 + "description": "A welcoming and friendly community" 249 + }, 250 + "customPds": { 251 + "name": "Use a custom PDS", 252 + "description": "Already have a PDS? Enter its address." 253 + } 254 + } 255 + }, 256 + "composer": { 257 + "newHighlight": "New highlight", 258 + "newAnnotation": "New annotation", 259 + "newNote": "New note", 260 + "saveHighlight": "Save highlight", 261 + "postAnnotation": "Post annotation", 262 + "postNote": "Post note", 263 + "highlightHint": "Saving a passage without a comment. Add text below to turn it into an annotation.", 264 + "addQuote": "+ Add a quote from the page", 265 + "quotePlaceholder": "Paste or type the text you're annotating…", 266 + "removeQuote": "Remove Quote", 267 + "thoughtsPlaceholder": "Add your thoughts on this passage…", 268 + "mindPlaceholder": "What's on your mind?", 269 + "tagsPlaceholder": "Add tags…", 270 + "contentWarning": "Content Warning", 271 + "contentWarningCount": "Content Warning ({{count}})", 272 + "cancel": "Cancel", 273 + "failedToPost": "Failed to post", 274 + "labels": { 275 + "sexual": "Sexual", 276 + "nudity": "Nudity", 277 + "violence": "Violence", 278 + "gore": "Gore", 279 + "spam": "Spam", 280 + "misleading": "Misleading" 281 + } 282 + }, 283 + "card": { 284 + "addedTo": "Added to", 285 + "addedToLower": "added to", 286 + "and": "and", 287 + "communityBookmark": "Community bookmark", 288 + "openInSemble": "Open in Semble", 289 + "deleteConfirm": "Delete this item?", 290 + "hideContent": "Hide Content", 291 + "show": "Show", 292 + "edited": "(edited)", 293 + "annotate": "Annotate", 294 + "untitledBookmark": "Untitled Bookmark", 295 + "addNotePlaceholder": "Add your note to convert this highlight into an annotation…", 296 + "addToCollectionTitle": "Add to Collection", 297 + "annotateTitle": "Annotate this highlight", 298 + "editTitle": "Edit", 299 + "deleteTitle": "Delete", 300 + "report": "Report", 301 + "muteUser": "Mute @{{handle}}", 302 + "blockUser": "Block @{{handle}}", 303 + "convertToAnnotation": "Convert to annotation", 304 + "justNow": "just now", 305 + "labelDescriptions": { 306 + "sexual": "Sexual Content", 307 + "nudity": "Nudity", 308 + "violence": "Violence", 309 + "gore": "Graphic Content", 310 + "spam": "Spam", 311 + "misleading": "Misleading" 312 + } 313 + }, 314 + "profileHoverCard": { 315 + "viewProfile": "View Profile", 316 + "notFound": "Profile not found" 317 + }, 318 + "replyList": { 319 + "noReplies": "No replies yet" 320 + }, 321 + "shareMenu": { 322 + "sembleIntegration": "Semble Integration", 323 + "openOnSemble": "Open on Semble", 324 + "copySembleLink": "Copy Semble Link", 325 + "copyLink": "Copy Link", 326 + "shareViaApp": "Share via App", 327 + "copyUniversalLink": "Copy Universal Link", 328 + "moreOptions": "More Options…", 329 + "copied": "Copied!" 330 + }, 331 + "addToCollection": { 332 + "title": "Add to Collection", 333 + "loading": "Loading collections…", 334 + "collectionNameLabel": "Collection name", 335 + "namePlaceholder": "My Collection", 336 + "descriptionLabel": "Description (optional)", 337 + "descriptionPlaceholder": "What's this collection about?", 338 + "iconLabel": "Icon", 339 + "iconsTab": "Icons", 340 + "emojisTab": "Emojis", 341 + "selected": "Selected:", 342 + "back": "Back", 343 + "create": "Create", 344 + "creating": "Creating…", 345 + "newCollectionButton": "New Collection", 346 + "createNewDescription": "Create a new collection", 347 + "none": "No collections yet", 348 + "done": "Done", 349 + "failedLoad": "Failed to load collections", 350 + "failedAdd": "Failed to add to collection", 351 + "failedCreate": "Failed to create collection" 352 + }, 353 + "editItem": { 354 + "editAnnotation": "Edit Annotation", 355 + "editHighlight": "Edit Highlight", 356 + "editBookmark": "Edit Bookmark", 357 + "textLabel": "Text", 358 + "textPlaceholder": "Write your annotation…", 359 + "colorLabel": "Color", 360 + "tagsLabel": "Tags", 361 + "tagPlaceholder": "Add a tag…", 362 + "contentWarning": "Content Warning", 363 + "cancel": "Cancel", 364 + "save": "Save", 365 + "saving": "Saving…", 366 + "failedSave": "Failed to save changes. Please try again.", 367 + "titleLabel": "Title", 368 + "titlePlaceholder": "Bookmark title", 369 + "descriptionLabel": "Description", 370 + "descriptionPlaceholder": "Optional description…" 371 + }, 372 + "editCollection": { 373 + "title": "Edit Collection", 374 + "nameLabel": "Collection name", 375 + "namePlaceholder": "My Collection", 376 + "descriptionLabel": "Description (optional)", 377 + "descriptionPlaceholder": "What's this collection about?", 378 + "iconLabel": "Icon", 379 + "iconsTab": "Icons", 380 + "emojisTab": "Emojis", 381 + "selected": "Selected:", 382 + "cancel": "Cancel", 383 + "save": "Save Changes", 384 + "saving": "Saving…", 385 + "failedUpdate": "Failed to update collection", 386 + "errorUpdating": "An error occurred while updating" 387 + }, 388 + "externalLink": { 389 + "title": "Leaving Margin", 390 + "message": "You're about to visit an external site.", 391 + "alwaysAllow": "Always allow links to {{hostname}}", 392 + "cancel": "Cancel", 393 + "open": "Open Link" 394 + }, 395 + "report": { 396 + "submitted": "Report submitted", 397 + "submittedMessage": "Thank you. We'll review this shortly.", 398 + "titleUser": "Report @{{handle}}", 399 + "titleGeneric": "Report user", 400 + "reportingContent": "Reporting specific content", 401 + "issueLabel": "What's the issue?", 402 + "cancel": "Cancel", 403 + "submit": "Submit Report", 404 + "submitting": "Submitting…", 405 + "detailsPlaceholder": "Additional details (optional)", 406 + "reasons": { 407 + "spam": "Spam", 408 + "ruleViolation": "Rule violation", 409 + "misleading": "Misleading", 410 + "rudeOrHarassing": "Rude or harassing", 411 + "inappropriateContent": "Inappropriate content", 412 + "other": "Other" 413 + } 414 + }, 415 + "editProfile": { 416 + "title": "Edit Profile", 417 + "avatarLabel": "Avatar", 418 + "uploadButton": "Upload", 419 + "uploading": "Uploading…", 420 + "displayNameLabel": "Display Name", 421 + "bioLabel": "Bio", 422 + "websiteLabel": "Website", 423 + "linksLabel": "Links", 424 + "addLinkPlaceholder": "Add a link…", 425 + "cancel": "Cancel", 426 + "save": "Save", 427 + "saving": "Saving…", 428 + "avatarTypeError": "Please select a JPEG or PNG image", 429 + "avatarSizeError": "Image must be under 2MB", 430 + "avatarUploadError": "Failed to upload: {{message}}" 431 + }, 432 + "iosShortcut": { 433 + "title": "Save from iOS Safari", 434 + "howTo": "How to use the shortcut", 435 + "step1Title": "Install the shortcut", 436 + "step1Link": "Get iOS Shortcut", 437 + "step2Title": "Generate an API Key", 438 + "step2Description": "Create a new key on this settings page and copy it.", 439 + "step3Title": "Configure the shortcut", 440 + "step3Description": "In the Shortcuts app, click the menu on the Save to Margin shortcut, and paste your API key in the Text action right below the setup comment.", 441 + "step4Title": "To Bookmark a page", 442 + "step4Description": "Don't select any text. Click the menu in Safari, press Share, and select Save to Margin.", 443 + "step5Title": "To Highlight text", 444 + "step5Description": "Select text on the page, click the menu, press Share, and select Save to Margin. Leave the Note field empty.", 445 + "step6Title": "To Add an Annotation", 446 + "step6Description": "Select text, share to Save to Margin (via the menu), enter your custom note in the Note field, and press Done!", 447 + "gotIt": "Got it" 448 + }, 449 + "editHistory": { 450 + "title": "Edit History", 451 + "noHistory": "No edit history found.", 452 + "currentVersion": "Current Version", 453 + "previousVersion": "Previous Version", 454 + "editedAgo": "Edited {{time}} ago", 455 + "postedAgo": "Posted {{time}} ago", 456 + "timeAgo": "{{time}} ago", 457 + "close": "Close", 458 + "failedLoad": "Failed to load edit history" 459 + }, 460 + "settings": { 461 + "title": "Settings", 462 + "sections": { 463 + "profile": "Profile", 464 + "appearance": "Appearance", 465 + "language": "Language", 466 + "batchImport": "Batch Import Highlights", 467 + "apiKeys": "API Keys", 468 + "moderation": "Moderation", 469 + "contentFiltering": "Content Filtering", 470 + "iosShortcut": "iOS Shortcut" 309 471 }, 310 - "profileHoverCard": { 311 - "viewProfile": "View Profile", 312 - "notFound": "Profile not found" 472 + "language": { 473 + "label": "Interface Language", 474 + "description": "Choose the language for the Margin interface." 313 475 }, 314 - "replyList": { 315 - "noReplies": "No replies yet" 476 + "appearance": { 477 + "disableExternalLinkWarning": "Disable external link warning", 478 + "disableExternalLinkWarningDesc": "Don't ask for confirmation when opening external links", 479 + "communityBookmarks": "Share bookmarks to community feed", 480 + "communityBookmarksDesc": "Your saved bookmarks will appear in the community bookmarks feed" 316 481 }, 317 - "shareMenu": { 318 - "sembleIntegration": "Semble Integration", 319 - "openOnSemble": "Open on Semble", 320 - "copySembleLink": "Copy Semble Link", 321 - "copyLink": "Copy Link", 322 - "shareViaApp": "Share via App", 323 - "copyUniversalLink": "Copy Universal Link", 324 - "moreOptions": "More Options…", 325 - "copied": "Copied!" 482 + "batchImport": { 483 + "description": "Upload highlights from CSV. Required: url, text. Optional: title, tags, color, created_at" 326 484 }, 327 - "addToCollection": { 328 - "title": "Add to Collection", 329 - "loading": "Loading collections…", 330 - "collectionNameLabel": "Collection name", 331 - "namePlaceholder": "My Collection", 332 - "descriptionLabel": "Description (optional)", 333 - "descriptionPlaceholder": "What's this collection about?", 334 - "iconLabel": "Icon", 335 - "iconsTab": "Icons", 336 - "emojisTab": "Emojis", 337 - "selected": "Selected:", 338 - "back": "Back", 339 - "create": "Create", 340 - "creating": "Creating…", 341 - "newCollectionButton": "New Collection", 342 - "createNewDescription": "Create a new collection", 343 - "none": "No collections yet", 344 - "done": "Done", 345 - "failedLoad": "Failed to load collections", 346 - "failedAdd": "Failed to add to collection", 347 - "failedCreate": "Failed to create collection" 485 + "apiKeys": { 486 + "description": "For the iOS shortcut and other apps", 487 + "keyNamePlaceholder": "Key name, e.g. iOS Shortcut", 488 + "generate": "Generate", 489 + "copyNow": "Copy now - you won't see this again!", 490 + "empty": "No API keys yet. Create one to use with the browser extension.", 491 + "created": "Created {{date}}", 492 + "revokeConfirm": "Revoke this key? Apps using it will stop working." 348 493 }, 349 - "editItem": { 350 - "editAnnotation": "Edit Annotation", 351 - "editHighlight": "Edit Highlight", 352 - "editBookmark": "Edit Bookmark", 353 - "textLabel": "Text", 354 - "textPlaceholder": "Write your annotation…", 355 - "colorLabel": "Color", 356 - "tagsLabel": "Tags", 357 - "tagPlaceholder": "Add a tag…", 358 - "contentWarning": "Content Warning", 359 - "cancel": "Cancel", 360 - "save": "Save", 361 - "saving": "Saving…", 362 - "failedSave": "Failed to save changes. Please try again.", 363 - "titleLabel": "Title", 364 - "titlePlaceholder": "Bookmark title", 365 - "descriptionLabel": "Description", 366 - "descriptionPlaceholder": "Optional description…" 494 + "moderation": { 495 + "description": "Manage blocked and muted accounts", 496 + "blockedAccounts": "Blocked accounts ({{count}})", 497 + "noBlocked": "No blocked accounts", 498 + "unblock": "Unblock", 499 + "mutedAccounts": "Muted accounts ({{count}})", 500 + "noMuted": "No muted accounts", 501 + "unmute": "Unmute" 367 502 }, 368 - "editCollection": { 369 - "title": "Edit Collection", 370 - "nameLabel": "Collection name", 371 - "namePlaceholder": "My Collection", 372 - "descriptionLabel": "Description (optional)", 373 - "descriptionPlaceholder": "What's this collection about?", 374 - "iconLabel": "Icon", 375 - "iconsTab": "Icons", 376 - "emojisTab": "Emojis", 377 - "selected": "Selected:", 378 - "cancel": "Cancel", 379 - "save": "Save Changes", 380 - "saving": "Saving…", 381 - "failedUpdate": "Failed to update collection", 382 - "errorUpdating": "An error occurred while updating" 503 + "contentFiltering": { 504 + "description": "Subscribe to labelers and configure how labeled content appears", 505 + "subscribedLabelers": "Subscribed Labelers", 506 + "noLabelers": "No labelers subscribed", 507 + "labelerDidPlaceholder": "did:plc:… (labeler DID)", 508 + "remove": "Remove", 509 + "add": "Add", 510 + "labelVisibility": "Label Visibility", 511 + "labelVisibilityDesc": "Choose how to handle each label type: Warn shows a blur overlay, Hide removes content entirely, Ignore shows content normally.", 512 + "warn": "Warn", 513 + "hide": "Hide", 514 + "ignore": "Ignore" 383 515 }, 384 - "externalLink": { 385 - "title": "Leaving Margin", 386 - "message": "You're about to visit an external site.", 387 - "alwaysAllow": "Always allow links to {{hostname}}", 388 - "cancel": "Cancel", 389 - "open": "Open Link" 516 + "iosShortcut": { 517 + "description": "Save pages to Margin from Safari on iPhone and iPad", 518 + "setupButton": "Setup iOS Shortcut" 390 519 }, 391 - "report": { 392 - "submitted": "Report submitted", 393 - "submittedMessage": "Thank you. We'll review this shortly.", 394 - "titleUser": "Report @{{handle}}", 395 - "titleGeneric": "Report user", 396 - "reportingContent": "Reporting specific content", 397 - "issueLabel": "What's the issue?", 398 - "cancel": "Cancel", 399 - "submit": "Submit Report", 400 - "submitting": "Submitting…", 401 - "detailsPlaceholder": "Additional details (optional)", 402 - "reasons": { 403 - "spam": "Spam", 404 - "ruleViolation": "Rule violation", 405 - "misleading": "Misleading", 406 - "rudeOrHarassing": "Rude or harassing", 407 - "inappropriateContent": "Inappropriate content", 408 - "other": "Other" 409 - } 520 + "logout": "Log out" 521 + }, 522 + "new": { 523 + "signInRequired": "Sign in to create", 524 + "needsAccount": "You need a Bluesky account", 525 + "signInButton": "Sign in with Bluesky", 526 + "composeTitle": "Compose", 527 + "composeTagline": "Highlight a passage, leave a note, or annotate a page — all from here.", 528 + "urlLabel": "URL to annotate", 529 + "urlPlaceholder": "https://example.com/article" 530 + }, 531 + "annotationDetail": { 532 + "back": "Back", 533 + "replies": "Replies ({{count}})", 534 + "replyingTo": "Replying to", 535 + "replyPlaceholder": "Write a reply…", 536 + "reply": "Reply", 537 + "signInToReply": "Sign in to reply", 538 + "logIn": "Log in", 539 + "notFound": "Not found", 540 + "mayBeDeleted": "This may have been deleted.", 541 + "backToFeed": "Back to Feed", 542 + "deleteReplyConfirm": "Delete this reply?", 543 + "failedReply": "Failed to post reply: {{message}}", 544 + "failedDelete": "Failed to delete: {{message}}", 545 + "failedResolve": "Failed to resolve handle: {{message}}" 546 + }, 547 + "urlPage": { 548 + "title": "URL Annotations", 549 + "description": "Enter a URL to see all public annotations and highlights from the Margin community.", 550 + "urlPlaceholder": "https://example.com/article", 551 + "view": "View", 552 + "myAnnotations": "My Annotations", 553 + "share": "Share", 554 + "copied": "Copied!", 555 + "contributor_one": "{{count}} contributor", 556 + "contributor_other": "{{count}} contributors", 557 + "loadingAnnotations": "Loading annotations…", 558 + "blankCanvas": "This page is a blank canvas", 559 + "blankCanvasMessage": "No one's left notes here yet. Want to be the first? Grab the Margin extension and share what you're thinking.", 560 + "tabs": { 561 + "all": "All", 562 + "annotations": "Annotations", 563 + "highlights": "Highlights", 564 + "bookmarks": "Bookmarks", 565 + "collections": "Collections" 410 566 }, 411 - "editProfile": { 412 - "title": "Edit Profile", 413 - "avatarLabel": "Avatar", 414 - "uploadButton": "Upload", 415 - "uploading": "Uploading…", 416 - "displayNameLabel": "Display Name", 417 - "bioLabel": "Bio", 418 - "websiteLabel": "Website", 419 - "linksLabel": "Links", 420 - "addLinkPlaceholder": "Add a link…", 421 - "cancel": "Cancel", 422 - "save": "Save", 423 - "saving": "Saving…", 424 - "avatarTypeError": "Please select a JPEG or PNG image", 425 - "avatarSizeError": "Image must be under 2MB", 426 - "avatarUploadError": "Failed to upload: {{message}}" 567 + "noAnnotationsYet": "No annotations yet", 568 + "noAnnotationsMessage": "Nobody has left a written note on this page.", 569 + "noHighlightsYet": "No highlights yet", 570 + "noHighlightsMessage": "Nobody has highlighted a passage from this page.", 571 + "loadMore": "Load more", 572 + "loading": "Loading…", 573 + "failedLoadMore": "Failed to load more: {{message}}" 574 + }, 575 + "userUrlPage": { 576 + "on": "on", 577 + "loadingAnnotations": "Loading annotations…", 578 + "noUrl": "No URL specified", 579 + "noUrlMessage": "Please provide a URL to view annotations.", 580 + "noItems": "No items found", 581 + "noItemsMessage": "{{name}} hasn't annotated this page yet.", 582 + "noAnnotations": "No annotations", 583 + "noHighlights": "No highlights", 584 + "loadMore": "Load more", 585 + "loading": "Loading…", 586 + "failedLoadMore": "Failed to load more: {{message}}" 587 + }, 588 + "adminModeration": { 589 + "accessDenied": "Access Denied", 590 + "accessDeniedMessage": "You don't have permission to access the moderation dashboard.", 591 + "title": "Moderation", 592 + "stats": "{{pending}} pending · {{total}} total reports", 593 + "tabs": { 594 + "reports": "Reports", 595 + "actions": "Actions", 596 + "labels": "Labels" 427 597 }, 428 - "iosShortcut": { 429 - "title": "Save from iOS Safari", 430 - "howTo": "How to use the shortcut", 431 - "step1Title": "Install the shortcut", 432 - "step1Link": "Get iOS Shortcut", 433 - "step2Title": "Generate an API Key", 434 - "step2Description": "Create a new key on this settings page and copy it.", 435 - "step3Title": "Configure the shortcut", 436 - "step3Description": "In the Shortcuts app, click the menu on the Save to Margin shortcut, and paste your API key in the Text action right below the setup comment.", 437 - "step4Title": "To Bookmark a page", 438 - "step4Description": "Don't select any text. Click the menu in Safari, press Share, and select Save to Margin.", 439 - "step5Title": "To Highlight text", 440 - "step5Description": "Select text on the page, click the menu, press Share, and select Save to Margin. Leave the Note field empty.", 441 - "step6Title": "To Add an Annotation", 442 - "step6Description": "Select text, share to Save to Margin (via the menu), enter your custom note in the Note field, and press Done!", 443 - "gotIt": "Got it" 598 + "filters": { 599 + "all": "All", 600 + "pending": "Pending", 601 + "resolved": "Resolved", 602 + "dismissed": "Dismissed", 603 + "escalated": "Escalated" 444 604 }, 445 - "editHistory": { 446 - "title": "Edit History", 447 - "noHistory": "No edit history found.", 448 - "currentVersion": "Current Version", 449 - "previousVersion": "Previous Version", 450 - "editedAgo": "Edited {{time}} ago", 451 - "postedAgo": "Posted {{time}} ago", 452 - "timeAgo": "{{time}} ago", 453 - "close": "Close", 454 - "failedLoad": "Failed to load edit history" 605 + "reports": { 606 + "empty": "No reports", 607 + "emptyPending": "No pending reports to review.", 608 + "emptyFiltered": "No {{status}} reports found.", 609 + "reportedUser": "Reported User", 610 + "reporter": "Reporter", 611 + "details": "Details", 612 + "contentUri": "Content URI", 613 + "acknowledge": "Acknowledge", 614 + "dismiss": "Dismiss", 615 + "takedown": "Takedown" 455 616 }, 456 - "settings": { 457 - "title": "Settings", 458 - "sections": { 459 - "profile": "Profile", 460 - "appearance": "Appearance", 461 - "language": "Language", 462 - "batchImport": "Batch Import Highlights", 463 - "apiKeys": "API Keys", 464 - "moderation": "Moderation", 465 - "contentFiltering": "Content Filtering", 466 - "iosShortcut": "iOS Shortcut" 467 - }, 468 - "language": { 469 - "label": "Interface Language", 470 - "description": "Choose the language for the Margin interface." 471 - }, 472 - "appearance": { 473 - "disableExternalLinkWarning": "Disable external link warning", 474 - "disableExternalLinkWarningDesc": "Don't ask for confirmation when opening external links", 475 - "communityBookmarks": "Share bookmarks to community feed", 476 - "communityBookmarksDesc": "Your saved bookmarks will appear in the community bookmarks feed" 477 - }, 478 - "batchImport": { 479 - "description": "Upload highlights from CSV. Required: url, text. Optional: title, tags, color, created_at" 480 - }, 481 - "apiKeys": { 482 - "description": "For the iOS shortcut and other apps", 483 - "keyNamePlaceholder": "Key name, e.g. iOS Shortcut", 484 - "generate": "Generate", 485 - "copyNow": "Copy now - you won't see this again!", 486 - "empty": "No API keys yet. Create one to use with the browser extension.", 487 - "created": "Created {{date}}", 488 - "revokeConfirm": "Revoke this key? Apps using it will stop working." 489 - }, 490 - "moderation": { 491 - "description": "Manage blocked and muted accounts", 492 - "blockedAccounts": "Blocked accounts ({{count}})", 493 - "noBlocked": "No blocked accounts", 494 - "unblock": "Unblock", 495 - "mutedAccounts": "Muted accounts ({{count}})", 496 - "noMuted": "No muted accounts", 497 - "unmute": "Unmute" 498 - }, 499 - "contentFiltering": { 500 - "description": "Subscribe to labelers and configure how labeled content appears", 501 - "subscribedLabelers": "Subscribed Labelers", 502 - "noLabelers": "No labelers subscribed", 503 - "labelerDidPlaceholder": "did:plc:… (labeler DID)", 504 - "remove": "Remove", 505 - "add": "Add", 506 - "labelVisibility": "Label Visibility", 507 - "labelVisibilityDesc": "Choose how to handle each label type: Warn shows a blur overlay, Hide removes content entirely, Ignore shows content normally.", 508 - "warn": "Warn", 509 - "hide": "Hide", 510 - "ignore": "Ignore" 511 - }, 512 - "iosShortcut": { 513 - "description": "Save pages to Margin from Safari on iPhone and iPad", 514 - "setupButton": "Setup iOS Shortcut" 515 - }, 516 - "logout": "Log out" 617 + "reasons": { 618 + "spam": "Spam", 619 + "violation": "Rule Violation", 620 + "misleading": "Misleading", 621 + "sexual": "Inappropriate", 622 + "rude": "Rude / Harassing", 623 + "other": "Other" 517 624 }, 518 - "new": { 519 - "signInRequired": "Sign in to create", 520 - "needsAccount": "You need a Bluesky account", 521 - "signInButton": "Sign in with Bluesky", 522 - "composeTitle": "Compose", 523 - "composeTagline": "Highlight a passage, leave a note, or annotate a page — all from here.", 524 - "urlLabel": "URL to annotate", 525 - "urlPlaceholder": "https://example.com/article" 625 + "actions": { 626 + "applyWarning": "Apply Content Warning", 627 + "applyWarningDesc": "Add a content warning label to a specific post or account. Users will see a blur overlay with the option to reveal.", 628 + "accountDid": "Account DID", 629 + "contentUri": "Content URI", 630 + "contentUriOptional": "optional — leave empty for account-level label", 631 + "labelType": "Label Type", 632 + "applyLabel": "Apply Label", 633 + "labelApplied": "Label applied" 526 634 }, 527 - "annotationDetail": { 528 - "back": "Back", 529 - "replies": "Replies ({{count}})", 530 - "replyingTo": "Replying to", 531 - "replyPlaceholder": "Write a reply…", 532 - "reply": "Reply", 533 - "signInToReply": "Sign in to reply", 534 - "logIn": "Log in", 535 - "notFound": "Not found", 536 - "mayBeDeleted": "This may have been deleted.", 537 - "backToFeed": "Back to Feed", 538 - "deleteReplyConfirm": "Delete this reply?", 539 - "failedReply": "Failed to post reply: {{message}}", 540 - "failedDelete": "Failed to delete: {{message}}", 541 - "failedResolve": "Failed to resolve handle: {{message}}" 635 + "labels": { 636 + "empty": "No labels", 637 + "emptyMessage": "No content labels have been applied yet.", 638 + "accountLevel": "Account-level label", 639 + "removeConfirm": "Remove this label?", 640 + "removeTitle": "Remove label" 641 + } 642 + }, 643 + "highlightImporter": { 644 + "clickToUpload": "Click to upload CSV", 645 + "processing": "Processing…", 646 + "requiredColumns": "Required columns: url, text | Optional: title, tags, color, created_at", 647 + "downloadTemplate": "Download Template", 648 + "importProgress": "Import Progress", 649 + "complete": "{{rate}}% complete", 650 + "failed_one": "{{count}} failed", 651 + "failed_other": "{{count}} failed", 652 + "importing": "Importing highlights…", 653 + "success": "Successfully imported {{count}} highlights!", 654 + "errorsTitle": "{{count}} errors during import", 655 + "row": "Row {{row}}: {{error}}", 656 + "moreErrors": "+{{count}} more errors", 657 + "importAnother": "Import Another File", 658 + "noHighlights": "No valid highlights found in CSV", 659 + "csvMustHaveUrl": "CSV must have a 'url' column", 660 + "csvMustHaveText": "CSV must have a 'text' column (also matches: highlight, excerpt)", 661 + "errorParsing": "Error parsing CSV: {{message}}" 662 + }, 663 + "common": { 664 + "loading": "Loading…", 665 + "cancel": "Cancel", 666 + "save": "Save", 667 + "close": "Close", 668 + "back": "Back", 669 + "continue": "Continue", 670 + "error": "Error", 671 + "retry": "Retry", 672 + "loadMore": "Load more", 673 + "new": "New" 674 + }, 675 + "about": { 676 + "nav": { 677 + "getExtension": "Get Extension", 678 + "install": "Install" 542 679 }, 543 - "urlPage": { 544 - "title": "URL Annotations", 545 - "description": "Enter a URL to see all public annotations and highlights from the Margin community.", 546 - "urlPlaceholder": "https://example.com/article", 547 - "view": "View", 548 - "myAnnotations": "My Annotations", 549 - "share": "Share", 550 - "copied": "Copied!", 551 - "contributor_one": "{{count}} contributor", 552 - "contributor_other": "{{count}} contributors", 553 - "loadingAnnotations": "Loading annotations…", 554 - "blankCanvas": "This page is a blank canvas", 555 - "blankCanvasMessage": "No one's left notes here yet. Want to be the first? Grab the Margin extension and share what you're thinking.", 556 - "tabs": { 557 - "all": "All", 558 - "annotations": "Annotations", 559 - "highlights": "Highlights", 560 - "bookmarks": "Bookmarks", 561 - "collections": "Collections" 562 - }, 563 - "noAnnotationsYet": "No annotations yet", 564 - "noAnnotationsMessage": "Nobody has left a written note on this page.", 565 - "noHighlightsYet": "No highlights yet", 566 - "noHighlightsMessage": "Nobody has highlighted a passage from this page.", 567 - "loadMore": "Load more", 568 - "loading": "Loading…", 569 - "failedLoadMore": "Failed to load more: {{message}}" 680 + "hero": { 681 + "openSource": "Fully open source", 682 + "headline": "Write on the margins", 683 + "headlineAccent": "of the internet.", 684 + "descriptionPre": "Margin is an open annotation layer for the internet. Highlight text, leave notes, and bookmark pages, all stored on your decentralized identity with the", 685 + "atProtocol": "AT Protocol", 686 + "descriptionPost": ". Not locked in a silo.", 687 + "openApp": "Open App", 688 + "getStarted": "Get Started", 689 + "installFor": "Install for {{browser}}" 570 690 }, 571 - "userUrlPage": { 572 - "on": "on", 573 - "loadingAnnotations": "Loading annotations…", 574 - "noUrl": "No URL specified", 575 - "noUrlMessage": "Please provide a URL to view annotations.", 576 - "noItems": "No items found", 577 - "noItemsMessage": "{{name}} hasn't annotated this page yet.", 578 - "noAnnotations": "No annotations", 579 - "noHighlights": "No highlights", 580 - "loadMore": "Load more", 581 - "loading": "Loading…", 582 - "failedLoadMore": "Failed to load more: {{message}}" 691 + "features": { 692 + "title": "Everything you need to engage with the web", 693 + "subtitle": "More than bookmarks. A full toolkit for reading, thinking, and sharing on the open web.", 694 + "annotations": { 695 + "title": "Annotations", 696 + "description": "Leave notes on any web page. Start discussions, share insights, or just jot down your thoughts for later." 697 + }, 698 + "highlights": { 699 + "title": "Highlights", 700 + "description": "Select and highlight text on any page with customizable colors. Your highlights are rendered inline with the CSS Highlights API." 701 + }, 702 + "bookmarks": { 703 + "title": "Bookmarks", 704 + "description": "Save pages with one click or a keyboard shortcut. All your bookmarks are synced to your AT Protocol identity." 705 + }, 706 + "collections": { 707 + "title": "Collections", 708 + "description": "Organize your annotations, highlights, and bookmarks into themed collections. Share them publicly or keep them private." 709 + }, 710 + "socialDiscovery": { 711 + "title": "Social Discovery", 712 + "description": "See what others are saying about the pages you visit. Discover annotations, trending tags, and connect with other readers." 713 + }, 714 + "tagsSearch": { 715 + "title": "Tags & Search", 716 + "description": "Tag your annotations for easy retrieval. Search by URL, tag, or content to find exactly what you're looking for." 717 + } 583 718 }, 584 - "adminModeration": { 585 - "accessDenied": "Access Denied", 586 - "accessDeniedMessage": "You don't have permission to access the moderation dashboard.", 587 - "title": "Moderation", 588 - "stats": "{{pending}} pending · {{total}} total reports", 589 - "tabs": { 590 - "reports": "Reports", 591 - "actions": "Actions", 592 - "labels": "Labels" 719 + "extension": { 720 + "badge": "Browser Extension", 721 + "title": "Your annotation toolkit,", 722 + "titleLine2": "right in the browser", 723 + "description": "The Margin extension brings the full annotation experience directly into every page you visit. Just select, annotate, and go.", 724 + "iosShortcut": "iOS Shortcut", 725 + "features": { 726 + "inlineOverlay": { 727 + "title": "Inline Overlay", 728 + "description": "See annotations and highlights rendered directly on the page. Uses the CSS Highlights API for beautiful, native-feeling text underlines." 593 729 }, 594 - "filters": { 595 - "all": "All", 596 - "pending": "Pending", 597 - "resolved": "Resolved", 598 - "dismissed": "Dismissed", 599 - "escalated": "Escalated" 730 + "contextMenu": { 731 + "title": "Context Menu & Selection", 732 + "description": "Right-click any selected text to annotate, highlight, or quote it. Or just right-click the page to bookmark it instantly." 600 733 }, 601 - "reports": { 602 - "empty": "No reports", 603 - "emptyPending": "No pending reports to review.", 604 - "emptyFiltered": "No {{status}} reports found.", 605 - "reportedUser": "Reported User", 606 - "reporter": "Reporter", 607 - "details": "Details", 608 - "contentUri": "Content URI", 609 - "acknowledge": "Acknowledge", 610 - "dismiss": "Dismiss", 611 - "takedown": "Takedown" 612 - }, 613 - "reasons": { 614 - "spam": "Spam", 615 - "violation": "Rule Violation", 616 - "misleading": "Misleading", 617 - "sexual": "Inappropriate", 618 - "rude": "Rude / Harassing", 619 - "other": "Other" 620 - }, 621 - "actions": { 622 - "applyWarning": "Apply Content Warning", 623 - "applyWarningDesc": "Add a content warning label to a specific post or account. Users will see a blur overlay with the option to reveal.", 624 - "accountDid": "Account DID", 625 - "contentUri": "Content URI", 626 - "contentUriOptional": "optional — leave empty for account-level label", 627 - "labelType": "Label Type", 628 - "applyLabel": "Apply Label", 629 - "labelApplied": "Label applied" 734 + "keyboard": { 735 + "title": "Keyboard Shortcuts", 736 + "description": "Toggle the overlay, bookmark the current page, or annotate selected text without reaching for the mouse." 630 737 }, 631 - "labels": { 632 - "empty": "No labels", 633 - "emptyMessage": "No content labels have been applied yet.", 634 - "accountLevel": "Account-level label", 635 - "removeConfirm": "Remove this label?", 636 - "removeTitle": "Remove label" 738 + "sidePanel": { 739 + "title": "Side Panel", 740 + "description": "Open the Margin side panel to browse annotations, bookmarks, and collections without leaving the page you're reading." 637 741 } 742 + } 638 743 }, 639 - "highlightImporter": { 640 - "clickToUpload": "Click to upload CSV", 641 - "processing": "Processing…", 642 - "requiredColumns": "Required columns: url, text | Optional: title, tags, color, created_at", 643 - "downloadTemplate": "Download Template", 644 - "importProgress": "Import Progress", 645 - "complete": "{{rate}}% complete", 646 - "failed_one": "{{count}} failed", 647 - "failed_other": "{{count}} failed", 648 - "importing": "Importing highlights…", 649 - "success": "Successfully imported {{count}} highlights!", 650 - "errorsTitle": "{{count}} errors during import", 651 - "row": "Row {{row}}: {{error}}", 652 - "moreErrors": "+{{count}} more errors", 653 - "importAnother": "Import Another File", 654 - "noHighlights": "No valid highlights found in CSV", 655 - "csvMustHaveUrl": "CSV must have a 'url' column", 656 - "csvMustHaveText": "CSV must have a 'text' column (also matches: highlight, excerpt)", 657 - "errorParsing": "Error parsing CSV: {{message}}" 744 + "protocol": { 745 + "badge": "Decentralized", 746 + "title": "Your data, your identity", 747 + "descriptionPre": "Margin is built on the", 748 + "descriptionPost": ", the open protocol that powers apps like Bluesky. Your annotations, highlights, and bookmarks are stored in your personal data repository, not locked in a silo.", 749 + "point0": "Sign in with your AT Protocol handle, no new account needed", 750 + "point1": "Your data lives in your PDS, portable and under your control", 751 + "point2": "Custom Lexicon schemas for annotations, highlights, collections & more", 752 + "point3": "Fully open source, check out the code and contribute" 658 753 }, 659 - "common": { 660 - "loading": "Loading…", 661 - "cancel": "Cancel", 662 - "save": "Save", 663 - "close": "Close", 664 - "back": "Back", 665 - "continue": "Continue", 666 - "error": "Error", 667 - "retry": "Retry", 668 - "loadMore": "Load more", 669 - "new": "New" 754 + "cta": { 755 + "title": "Start writing on the margins", 756 + "description": "Join the open annotation layer. Sign in with your AT Protocol identity and install the extension to get started.", 757 + "signIn": "Sign in", 758 + "viewGitHub": "View on GitHub", 759 + "viewTangled": "View on Tangled" 670 760 }, 671 - "about": { 672 - "nav": { 673 - "getExtension": "Get Extension", 674 - "install": "Install" 675 - }, 676 - "hero": { 677 - "openSource": "Fully open source", 678 - "headline": "Write on the margins", 679 - "headlineAccent": "of the internet.", 680 - "descriptionPre": "Margin is an open annotation layer for the internet. Highlight text, leave notes, and bookmark pages, all stored on your decentralized identity with the", 681 - "atProtocol": "AT Protocol", 682 - "descriptionPost": ". Not locked in a silo.", 683 - "openApp": "Open App", 684 - "getStarted": "Get Started", 685 - "installFor": "Install for {{browser}}" 686 - }, 687 - "features": { 688 - "title": "Everything you need to engage with the web", 689 - "subtitle": "More than bookmarks. A full toolkit for reading, thinking, and sharing on the open web.", 690 - "annotations": { 691 - "title": "Annotations", 692 - "description": "Leave notes on any web page. Start discussions, share insights, or just jot down your thoughts for later." 693 - }, 694 - "highlights": { 695 - "title": "Highlights", 696 - "description": "Select and highlight text on any page with customizable colors. Your highlights are rendered inline with the CSS Highlights API." 697 - }, 698 - "bookmarks": { 699 - "title": "Bookmarks", 700 - "description": "Save pages with one click or a keyboard shortcut. All your bookmarks are synced to your AT Protocol identity." 701 - }, 702 - "collections": { 703 - "title": "Collections", 704 - "description": "Organize your annotations, highlights, and bookmarks into themed collections. Share them publicly or keep them private." 705 - }, 706 - "socialDiscovery": { 707 - "title": "Social Discovery", 708 - "description": "See what others are saying about the pages you visit. Discover annotations, trending tags, and connect with other readers." 709 - }, 710 - "tagsSearch": { 711 - "title": "Tags & Search", 712 - "description": "Tag your annotations for easy retrieval. Search by URL, tag, or content to find exactly what you're looking for." 713 - } 714 - }, 715 - "extension": { 716 - "badge": "Browser Extension", 717 - "title": "Your annotation toolkit,", 718 - "titleLine2": "right in the browser", 719 - "description": "The Margin extension brings the full annotation experience directly into every page you visit. Just select, annotate, and go.", 720 - "iosShortcut": "iOS Shortcut", 721 - "features": { 722 - "inlineOverlay": { 723 - "title": "Inline Overlay", 724 - "description": "See annotations and highlights rendered directly on the page. Uses the CSS Highlights API for beautiful, native-feeling text underlines." 725 - }, 726 - "contextMenu": { 727 - "title": "Context Menu & Selection", 728 - "description": "Right-click any selected text to annotate, highlight, or quote it. Or just right-click the page to bookmark it instantly." 729 - }, 730 - "keyboard": { 731 - "title": "Keyboard Shortcuts", 732 - "description": "Toggle the overlay, bookmark the current page, or annotate selected text without reaching for the mouse." 733 - }, 734 - "sidePanel": { 735 - "title": "Side Panel", 736 - "description": "Open the Margin side panel to browse annotations, bookmarks, and collections without leaving the page you're reading." 737 - } 738 - } 739 - }, 740 - "protocol": { 741 - "badge": "Decentralized", 742 - "title": "Your data, your identity", 743 - "descriptionPre": "Margin is built on the", 744 - "descriptionPost": ", the open protocol that powers apps like Bluesky. Your annotations, highlights, and bookmarks are stored in your personal data repository, not locked in a silo.", 745 - "point0": "Sign in with your AT Protocol handle, no new account needed", 746 - "point1": "Your data lives in your PDS, portable and under your control", 747 - "point2": "Custom Lexicon schemas for annotations, highlights, collections & more", 748 - "point3": "Fully open source, check out the code and contribute" 749 - }, 750 - "cta": { 751 - "title": "Start writing on the margins", 752 - "description": "Join the open annotation layer. Sign in with your AT Protocol identity and install the extension to get started.", 753 - "signIn": "Sign in", 754 - "viewGitHub": "View on GitHub", 755 - "viewTangled": "View on Tangled" 756 - }, 757 - "footer": { 758 - "privacy": "Privacy", 759 - "terms": "Terms", 760 - "contact": "Contact" 761 - } 761 + "footer": { 762 + "privacy": "Privacy", 763 + "terms": "Terms", 764 + "contact": "Contact" 762 765 } 766 + } 763 767 }
+371 -371
web/public/locales/es/translation.json
··· 1 1 { 2 - "appTitle": "Margin", 3 - "nav": { 4 - "settings": "Ajustes", 5 - "annotations": "Anotaciones", 6 - "collections": "Colecciones", 7 - "activity": "Actividad", 8 - "signIn": "Iniciar sesión", 9 - "logOut": "Cerrar sesión", 10 - "themeLight": "Claro", 11 - "themeDark": "Oscuro", 12 - "themeSystem": "Sistema", 13 - "bookmarks": "Marcadores", 14 - "new": "Nuevo", 15 - "feed": "Feed", 16 - "highlights": "Resaltados" 17 - }, 18 - "pageTitles": { 19 - "home": "Inicio — Margin", 20 - "bookmarks": "Marcadores — Margin", 21 - "annotations": "Anotaciones — Margin", 22 - "search": "Buscar — Margin", 23 - "notifications": "Notificaciones — Margin", 24 - "new": "Nueva anotación — Margin", 25 - "settings": "Configuración — Margin", 26 - "collections": "Colecciones — Margin", 27 - "admin": "Admin — Margin", 28 - "highlights": "Resaltados — Margin" 29 - }, 30 - "sidebar": { 31 - "getExtension": "Obtén la extensión", 32 - "extensionTagline": "Resalta, anota y guarda marcadores desde cualquier página.", 33 - "downloadForFirefox": "Descargar para Firefox", 34 - "downloadForEdge": "Descargar para Edge", 35 - "downloadForChrome": "Descargar para Chrome", 36 - "trending": "Tendencias", 37 - "nothingTrending": "No hay nada en tendencia ahora mismo.", 38 - "searchPlaceholder": "Buscar personas, etiquetas, URLs…", 39 - "copyright": "© 2026 Padding Labs LLC" 40 - }, 41 - "mobileNav": { 42 - "iosShortcut": "Atajo de iOS" 43 - }, 44 - "feed": { 45 - "welcome": "Bienvenido a Margin", 46 - "welcomeTagline": "Un lugar tranquilo para anotar, resaltar y guardar lo que lees en la web.", 47 - "getStarted": "Empezar", 48 - "learnMore": "Más información", 49 - "tabs": { 50 - "recent": "Recientes", 51 - "popular": "Popular", 52 - "margin": "Margin", 53 - "semble": "Semble" 54 - }, 55 - "filters": { 56 - "all": "Todo", 57 - "annotations": "Anotaciones", 58 - "highlights": "Resaltados", 59 - "bookmarks": "Marcadores" 60 - }, 61 - "itemsWithTag": "Elementos con la etiqueta:", 62 - "clearFilter": "Limpiar filtro", 63 - "everyone": "Todos", 64 - "mine": "Mío", 65 - "defaultEmptyMessage": "Todavía no hay nada aquí — las anotaciones tuyas y de las personas a las que sigues aparecerán aquí.", 66 - "nothingHereYet": "Aún no hay nada aquí", 67 - "loading": "Cargando…" 68 - }, 69 - "discover": { 70 - "tabs": { 71 - "new": "Nuevo", 72 - "popular": "Popular", 73 - "forYou": "Para ti" 74 - }, 75 - "comingSoon": "Próximamente", 76 - "forYouNotAvailable": "Las recomendaciones personalizadas aún no están disponibles en este servidor.", 77 - "noDocumentsYet": "Aún no se han descubierto documentos. ¡Vuelve pronto!", 78 - "startAnnotating": "Empieza a anotar y resaltar para recibir recomendaciones personalizadas.", 79 - "loadMore": "Cargar más" 80 - }, 81 - "search": { 82 - "placeholder": "Buscar anotaciones, resaltados, marcadores…", 83 - "noResults": "No se encontraron resultados", 84 - "noResultsMessage": "No se encontró nada que coincida con \"{{query}}\". Prueba con otras palabras clave.", 85 - "emptyTitle": "Busca en tu biblioteca", 86 - "emptyMessage": "Busca anotaciones, resaltados y marcadores por palabra clave, URL o etiqueta.", 87 - "filters": { 88 - "all": "Todo", 89 - "annotations": "Anotaciones", 90 - "bookmarks": "Marcadores", 91 - "mine": "Mío", 92 - "highlights": "Resaltados" 93 - }, 94 - "resultCount": "{{count}}{{hasMore}} resultados para \"{{query}}\"", 95 - "loadMore": "Cargar más" 96 - }, 97 - "notifications": { 98 - "title": "Actividad", 99 - "noActivity": "Aún no hay actividad", 100 - "noActivityMessage": "Las interacciones con tu contenido aparecerán aquí.", 101 - "likedAnnotation": "le gustó tu anotación", 102 - "likedHighlight": "le dio me gusta a tu resaltado", 103 - "likedBookmark": "le dio me gusta a tu marcador", 104 - "likedReply": "le gustó tu respuesta", 105 - "likedPost": "le dio me gusta a tu publicación", 106 - "repliedToReply": "respondió a tu respuesta", 107 - "repliedToAnnotation": "respondió a tu anotación", 108 - "mentionedInAnnotation": "te mencionó en una anotación", 109 - "followedYou": "te empezó a seguir", 110 - "highlightedPage": "resaltó tu página", 111 - "inReplyTo": "en respuesta a", 112 - "aReply": "una respuesta", 113 - "anAnnotation": "una anotación" 2 + "appTitle": "Margin", 3 + "nav": { 4 + "settings": "Ajustes", 5 + "annotations": "Anotaciones", 6 + "collections": "Colecciones", 7 + "activity": "Actividad", 8 + "signIn": "Iniciar sesión", 9 + "logOut": "Cerrar sesión", 10 + "themeLight": "Claro", 11 + "themeDark": "Oscuro", 12 + "themeSystem": "Sistema", 13 + "bookmarks": "Marcadores", 14 + "new": "Nuevo", 15 + "feed": "Feed", 16 + "highlights": "Resaltados" 17 + }, 18 + "pageTitles": { 19 + "home": "Inicio — Margin", 20 + "bookmarks": "Marcadores — Margin", 21 + "annotations": "Anotaciones — Margin", 22 + "search": "Buscar — Margin", 23 + "notifications": "Notificaciones — Margin", 24 + "new": "Nueva anotación — Margin", 25 + "settings": "Configuración — Margin", 26 + "collections": "Colecciones — Margin", 27 + "admin": "Admin — Margin", 28 + "highlights": "Resaltados — Margin" 29 + }, 30 + "sidebar": { 31 + "getExtension": "Obtén la extensión", 32 + "extensionTagline": "Resalta, anota y guarda marcadores desde cualquier página.", 33 + "downloadForFirefox": "Descargar para Firefox", 34 + "downloadForEdge": "Descargar para Edge", 35 + "downloadForChrome": "Descargar para Chrome", 36 + "trending": "Tendencias", 37 + "nothingTrending": "No hay nada en tendencia ahora mismo.", 38 + "searchPlaceholder": "Buscar personas, etiquetas, URLs…", 39 + "copyright": "© 2026 Padding Labs LLC" 40 + }, 41 + "mobileNav": { 42 + "iosShortcut": "Atajo de iOS" 43 + }, 44 + "feed": { 45 + "welcome": "Bienvenido a Margin", 46 + "welcomeTagline": "Un lugar tranquilo para anotar, resaltar y guardar lo que lees en la web.", 47 + "getStarted": "Empezar", 48 + "learnMore": "Más información", 49 + "tabs": { 50 + "recent": "Recientes", 51 + "popular": "Popular", 52 + "margin": "Margin", 53 + "semble": "Semble" 114 54 }, 115 - "collections": { 116 - "title": "Colecciones", 117 - "subtitle": "Organiza tus anotaciones y resaltados", 118 - "none": "Aún no hay colecciones", 119 - "noneMessage": "Crea una colección para organizar tus resaltados y anotaciones.", 120 - "createButton": "Crear colección", 121 - "newTitle": "Nueva colección", 122 - "editTitle": "Editar colección", 123 - "namePlaceholder": "Mi colección", 124 - "namePlaceholderEdit": "Nombre de la colección", 125 - "nameLabel": "Nombre", 126 - "iconLabel": "Icono", 127 - "iconsTab": "Iconos", 128 - "emojisTab": "Emojis", 129 - "selectedIcon": "Seleccionado:", 130 - "descriptionLabel": "Descripción (opcional)", 131 - "descriptionPlaceholder": "¿Para qué es esta colección?", 132 - "descriptionPlaceholderEdit": "¿De qué trata esta colección?", 133 - "cancel": "Cancelar", 134 - "create": "Crear colección", 135 - "creating": "Creando…", 136 - "save": "Guardar cambios", 137 - "saving": "Guardando…", 138 - "deleteConfirm": "¿Eliminar esta colección?", 139 - "failedUpdate": "Error al actualizar la colección", 140 - "errorUpdating": "Ocurrió un error al actualizar", 141 - "itemCount_one": "{{count}} elemento", 142 - "itemCount_many": "", 143 - "itemCount_other": "{{count}} elementos" 144 - }, 145 - "collectionDetail": { 146 - "by": "por", 147 - "edit": "Editar colección", 148 - "delete": "Eliminar colección", 149 - "removeFromCollection": "Quitar de la colección", 150 - "viewInSemble": "Ver en Semble", 151 - "empty": "La colección está vacía", 152 - "notFound": "Colección no encontrada", 153 - "failedToLoad": "Error al cargar la colección", 154 - "deleteConfirm": "¿Eliminar esta colección?", 155 - "removeConfirm": "¿Eliminar de la colección?", 156 - "backLink": "Colecciones" 157 - }, 158 - "profile": { 159 - "notFound": "Usuario no encontrado", 160 - "notFoundMessage": "Este perfil no existe o no se pudo cargar.", 161 - "edit": "Editar", 162 - "viewInBluesky": "Ver perfil en Bluesky", 163 - "unblock": "Desbloquear a {{handle}}", 164 - "block": "Bloquear a {{handle}}", 165 - "unmute": "Desactivar silencio de {{handle}}", 166 - "mute": "Silenciar a {{handle}}", 167 - "report": "Reportar", 168 - "accountLabeled": "Cuenta etiquetada: {{description}}", 169 - "labelApplied": "Esta etiqueta fue aplicada por un servicio de moderación al que estás suscrito.", 170 - "show": "Mostrar", 171 - "hide": "Ocultar", 172 - "blockedBanner": "Has bloqueado a {{handle}}", 173 - "blockedMessage": "Su contenido está oculto de tus feeds.", 174 - "mutedBanner": "Has silenciado a {{handle}}", 175 - "mutedMessage": "Su contenido está oculto de tus feeds.", 176 - "blockedByBanner": "{{handle}} te ha bloqueado. No puedes interactuar con su contenido.", 177 - "unblock_action": "Desbloquear", 178 - "unmute_action": "Activar sonido", 179 - "emptyCollectionsOwn": "Aún no has creado ninguna colección.", 180 - "emptyCollectionsOther": "Sin colecciones", 181 - "itemCount_one": "{{count}} elemento", 182 - "itemCount_many": "", 183 - "itemCount_other": "{{count}} elementos", 184 - "emptyTabOwn": "Tus {{tab}} aparecerán aquí.", 185 - "emptyTabOther": "Aún no hay nada que ver aquí." 186 - }, 187 - "login": { 188 - "signInWith": "Inicia sesión con tu", 189 - "handleSuffix": "handle", 190 - "handlePlaceholder": "handle.margin.cafe", 191 - "connecting": "Conectando…", 192 - "continue": "Continuar", 193 - "createAccount": "Crear cuenta nueva", 194 - "termsPrefix": "Al iniciar sesión, aceptas nuestros", 195 - "termsLink": "Términos de servicio", 196 - "termsAnd": "y", 197 - "privacyLink": "Política de privacidad" 198 - }, 199 - "signUp": { 200 - "title": "Crea tu cuenta", 201 - "subtitle": "Margin se adhiere al", 202 - "atProtocol": "AT Protocol", 203 - "subtitleSuffix": ". Elige un proveedor para alojar tu cuenta.", 204 - "customPdsTitle": "Usa un PDS personalizado", 205 - "customPdsSubtitle": "Introduce la dirección del PDS que aloja tu cuenta.", 206 - "pdsAddressLabel": "Dirección del PDS", 207 - "pdsAddressPlaceholder": "pds.example.com", 208 - "connecting": "Conectando…", 209 - "back": "Volver", 210 - "continue": "Continuar", 211 - "invite": "Invitar", 212 - "providerError": "No se pudo conectar con este proveedor. Por favor, inténtalo de nuevo.", 213 - "customPdsError": "No se pudo conectar con ese PDS. Revisa la dirección.", 214 - "providers": { 215 - "margin": { 216 - "description": "La forma más fácil de empezar", 217 - "name": "Margin" 218 - }, 219 - "bluesky": { 220 - "name": "Bluesky", 221 - "description": "La comunidad más grande y popular" 222 - }, 223 - "blacksky": { 224 - "name": "Blacksky", 225 - "description": "Para la cultura — un espacio seguro para usuarios y aliados" 226 - }, 227 - "eurosky": { 228 - "name": "Eurosky", 229 - "description": "Eurosky es tu hogar europeo en la Atmosphere" 230 - }, 231 - "selfhostedSocial": { 232 - "name": "selfhosted.social", 233 - "description": "Un hogar para creadores, entusiastas y curiosos" 234 - }, 235 - "northsky": { 236 - "name": "Northsky", 237 - "description": "Una cooperativa canadiense propiedad de los trabajadores" 238 - }, 239 - "tophhie": { 240 - "name": "Tophhie", 241 - "description": "Una comunidad acogedora y amable" 242 - }, 243 - "customPds": { 244 - "name": "Usa un PDS personalizado", 245 - "description": "¿Ya tienes un PDS? Introduce su dirección." 246 - } 247 - } 248 - }, 249 - "composer": { 250 - "newHighlight": "Nuevo resaltado", 251 - "newAnnotation": "Nueva anotación", 252 - "newNote": "Nueva nota", 253 - "saveHighlight": "Guardar resaltado", 254 - "postAnnotation": "Publicar anotación", 255 - "postNote": "Publicar nota", 256 - "highlightHint": "Guardando un pasaje sin comentario. Añade texto debajo para convertirlo en una anotación.", 257 - "addQuote": "+ Añadir una cita de la página", 258 - "quotePlaceholder": "Pega o escribe el texto que estás anotando…", 259 - "removeQuote": "Eliminar cita", 260 - "thoughtsPlaceholder": "Añade tus reflexiones sobre este pasaje…", 261 - "mindPlaceholder": "¿Qué tienes en mente?", 262 - "tagsPlaceholder": "Añadir etiquetas…", 263 - "contentWarning": "Advertencia de contenido", 264 - "contentWarningCount": "Advertencia de contenido ({{count}})", 265 - "cancel": "Cancelar", 266 - "failedToPost": "Error al publicar", 267 - "labels": { 268 - "sexual": "Sexual", 269 - "nudity": "Desnudez", 270 - "violence": "Violencia", 271 - "gore": "Gore", 272 - "spam": "Spam", 273 - "misleading": "Engañoso" 274 - } 275 - }, 276 - "card": { 277 - "addedTo": "Añadido a", 278 - "addedToLower": "añadido a", 279 - "and": "y", 280 - "communityBookmark": "Marcador de la comunidad", 281 - "openInSemble": "Abrir en Semble", 282 - "deleteConfirm": "¿Eliminar este elemento?", 283 - "hideContent": "Ocultar contenido", 284 - "show": "Mostrar", 285 - "edited": "(editado)", 286 - "annotate": "Anotar", 287 - "untitledBookmark": "Marcador sin título", 288 - "addNotePlaceholder": "Añade tu nota para convertir este resaltado en una anotación…", 289 - "addToCollectionTitle": "Añadir a la colección", 290 - "annotateTitle": "Anotar este resaltado", 291 - "editTitle": "Editar", 292 - "deleteTitle": "Eliminar", 293 - "report": "Reportar", 294 - "muteUser": "Silenciar a {{handle}}", 295 - "blockUser": "Bloquear a {{handle}}", 296 - "convertToAnnotation": "Convertir en anotación", 297 - "justNow": "hace un momento", 298 - "labelDescriptions": { 299 - "sexual": "Contenido sexual", 300 - "nudity": "Desnudez", 301 - "violence": "Violencia", 302 - "gore": "Contenido gráfico", 303 - "spam": "Spam", 304 - "misleading": "Engañoso" 305 - } 306 - }, 307 - "profileHoverCard": { 308 - "viewProfile": "Ver perfil", 309 - "notFound": "Perfil no encontrado" 310 - }, 311 - "replyList": { 312 - "noReplies": "Aún no hay respuestas" 313 - }, 314 - "shareMenu": { 315 - "sembleIntegration": "Integración con Semble", 316 - "openOnSemble": "Abrir en Semble", 317 - "copySembleLink": "Copiar enlace de Semble", 318 - "copyLink": "Copiar enlace", 319 - "shareViaApp": "Compartir vía app", 320 - "copyUniversalLink": "Copiar enlace universal", 321 - "moreOptions": "Más opciones…", 322 - "copied": "¡Copiado!" 55 + "filters": { 56 + "all": "Todo", 57 + "annotations": "Anotaciones", 58 + "highlights": "Resaltados", 59 + "bookmarks": "Marcadores" 323 60 }, 324 - "addToCollection": { 325 - "title": "Añadir a la colección", 326 - "loading": "Cargando colecciones…", 327 - "collectionNameLabel": "Nombre de la colección", 328 - "namePlaceholder": "Mi colección", 329 - "descriptionLabel": "Descripción (opcional)", 330 - "descriptionPlaceholder": "¿De qué trata esta colección?", 331 - "iconLabel": "Icono", 332 - "iconsTab": "Iconos", 333 - "emojisTab": "Emojis", 334 - "selected": "Seleccionado:", 335 - "back": "Volver", 336 - "create": "Crear", 337 - "creating": "Creando…", 338 - "newCollectionButton": "Nueva colección", 339 - "createNewDescription": "Crear una nueva colección", 340 - "none": "Aún no hay colecciones", 341 - "done": "Hecho", 342 - "failedLoad": "Error al cargar las colecciones", 343 - "failedAdd": "Error al añadir a la colección", 344 - "failedCreate": "Error al crear la colección" 61 + "itemsWithTag": "Elementos con la etiqueta:", 62 + "clearFilter": "Limpiar filtro", 63 + "everyone": "Todos", 64 + "mine": "Mío", 65 + "defaultEmptyMessage": "Todavía no hay nada aquí — las anotaciones tuyas y de las personas a las que sigues aparecerán aquí.", 66 + "nothingHereYet": "Aún no hay nada aquí", 67 + "loading": "Cargando…" 68 + }, 69 + "discover": { 70 + "tabs": { 71 + "new": "Nuevo", 72 + "popular": "Popular", 73 + "forYou": "Para ti" 345 74 }, 346 - "editItem": { 347 - "editAnnotation": "Editar anotación", 348 - "editHighlight": "Editar resaltado", 349 - "editBookmark": "Editar marcador", 350 - "textLabel": "Texto", 351 - "textPlaceholder": "Escribe tu anotación…", 352 - "colorLabel": "Color", 353 - "tagsLabel": "Etiquetas", 354 - "tagPlaceholder": "Añadir una etiqueta…", 355 - "contentWarning": "Advertencia de contenido", 356 - "cancel": "Cancelar", 357 - "save": "Guardar", 358 - "saving": "Guardando…", 359 - "failedSave": "Error al guardar los cambios. Por favor, inténtalo de nuevo.", 360 - "titleLabel": "Título", 361 - "titlePlaceholder": "Título del marcador", 362 - "descriptionLabel": "Descripción", 363 - "descriptionPlaceholder": "Descripción opcional…" 75 + "comingSoon": "Próximamente", 76 + "forYouNotAvailable": "Las recomendaciones personalizadas aún no están disponibles en este servidor.", 77 + "noDocumentsYet": "Aún no se han descubierto documentos. ¡Vuelve pronto!", 78 + "startAnnotating": "Empieza a anotar y resaltar para recibir recomendaciones personalizadas.", 79 + "loadMore": "Cargar más" 80 + }, 81 + "search": { 82 + "placeholder": "Buscar anotaciones, resaltados, marcadores…", 83 + "noResults": "No se encontraron resultados", 84 + "noResultsMessage": "No se encontró nada que coincida con \"{{query}}\". Prueba con otras palabras clave.", 85 + "emptyTitle": "Busca en tu biblioteca", 86 + "emptyMessage": "Busca anotaciones, resaltados y marcadores por palabra clave, URL o etiqueta.", 87 + "filters": { 88 + "all": "Todo", 89 + "annotations": "Anotaciones", 90 + "bookmarks": "Marcadores", 91 + "mine": "Mío", 92 + "highlights": "Resaltados" 364 93 }, 365 - "editCollection": { 366 - "title": "Editar colección", 367 - "nameLabel": "Nombre de la colección", 368 - "namePlaceholder": "Mi colección", 369 - "descriptionLabel": "Descripción (opcional)", 370 - "descriptionPlaceholder": "¿De qué trata esta colección?", 371 - "iconLabel": "Icono", 372 - "iconsTab": "Iconos", 373 - "emojisTab": "Emojis", 374 - "selected": "Seleccionado:", 375 - "cancel": "Cancelar", 376 - "save": "Guardar cambios" 94 + "resultCount": "{{count}}{{hasMore}} resultados para \"{{query}}\"", 95 + "loadMore": "Cargar más" 96 + }, 97 + "notifications": { 98 + "title": "Actividad", 99 + "noActivity": "Aún no hay actividad", 100 + "noActivityMessage": "Las interacciones con tu contenido aparecerán aquí.", 101 + "likedAnnotation": "le gustó tu anotación", 102 + "likedHighlight": "le dio me gusta a tu resaltado", 103 + "likedBookmark": "le dio me gusta a tu marcador", 104 + "likedReply": "le gustó tu respuesta", 105 + "likedPost": "le dio me gusta a tu publicación", 106 + "repliedToReply": "respondió a tu respuesta", 107 + "repliedToAnnotation": "respondió a tu anotación", 108 + "mentionedInAnnotation": "te mencionó en una anotación", 109 + "followedYou": "te empezó a seguir", 110 + "highlightedPage": "resaltó tu página", 111 + "inReplyTo": "en respuesta a", 112 + "aReply": "una respuesta", 113 + "anAnnotation": "una anotación" 114 + }, 115 + "collections": { 116 + "title": "Colecciones", 117 + "subtitle": "Organiza tus anotaciones y resaltados", 118 + "none": "Aún no hay colecciones", 119 + "noneMessage": "Crea una colección para organizar tus resaltados y anotaciones.", 120 + "createButton": "Crear colección", 121 + "newTitle": "Nueva colección", 122 + "editTitle": "Editar colección", 123 + "namePlaceholder": "Mi colección", 124 + "namePlaceholderEdit": "Nombre de la colección", 125 + "nameLabel": "Nombre", 126 + "iconLabel": "Icono", 127 + "iconsTab": "Iconos", 128 + "emojisTab": "Emojis", 129 + "selectedIcon": "Seleccionado:", 130 + "descriptionLabel": "Descripción (opcional)", 131 + "descriptionPlaceholder": "¿Para qué es esta colección?", 132 + "descriptionPlaceholderEdit": "¿De qué trata esta colección?", 133 + "cancel": "Cancelar", 134 + "create": "Crear colección", 135 + "creating": "Creando…", 136 + "save": "Guardar cambios", 137 + "saving": "Guardando…", 138 + "deleteConfirm": "¿Eliminar esta colección?", 139 + "failedUpdate": "Error al actualizar la colección", 140 + "errorUpdating": "Ocurrió un error al actualizar", 141 + "itemCount_one": "{{count}} elemento", 142 + "itemCount_many": "", 143 + "itemCount_other": "{{count}} elementos" 144 + }, 145 + "collectionDetail": { 146 + "by": "por", 147 + "edit": "Editar colección", 148 + "delete": "Eliminar colección", 149 + "removeFromCollection": "Quitar de la colección", 150 + "viewInSemble": "Ver en Semble", 151 + "empty": "La colección está vacía", 152 + "notFound": "Colección no encontrada", 153 + "failedToLoad": "Error al cargar la colección", 154 + "deleteConfirm": "¿Eliminar esta colección?", 155 + "removeConfirm": "¿Eliminar de la colección?", 156 + "backLink": "Colecciones" 157 + }, 158 + "profile": { 159 + "notFound": "Usuario no encontrado", 160 + "notFoundMessage": "Este perfil no existe o no se pudo cargar.", 161 + "edit": "Editar", 162 + "viewInBluesky": "Ver perfil en Bluesky", 163 + "unblock": "Desbloquear a {{handle}}", 164 + "block": "Bloquear a {{handle}}", 165 + "unmute": "Desactivar silencio de {{handle}}", 166 + "mute": "Silenciar a {{handle}}", 167 + "report": "Reportar", 168 + "accountLabeled": "Cuenta etiquetada: {{description}}", 169 + "labelApplied": "Esta etiqueta fue aplicada por un servicio de moderación al que estás suscrito.", 170 + "show": "Mostrar", 171 + "hide": "Ocultar", 172 + "blockedBanner": "Has bloqueado a {{handle}}", 173 + "blockedMessage": "Su contenido está oculto de tus feeds.", 174 + "mutedBanner": "Has silenciado a {{handle}}", 175 + "mutedMessage": "Su contenido está oculto de tus feeds.", 176 + "blockedByBanner": "{{handle}} te ha bloqueado. No puedes interactuar con su contenido.", 177 + "unblock_action": "Desbloquear", 178 + "unmute_action": "Activar sonido", 179 + "emptyCollectionsOwn": "Aún no has creado ninguna colección.", 180 + "emptyCollectionsOther": "Sin colecciones", 181 + "itemCount_one": "{{count}} elemento", 182 + "itemCount_many": "", 183 + "itemCount_other": "{{count}} elementos", 184 + "emptyTabOwn": "Tus {{tab}} aparecerán aquí.", 185 + "emptyTabOther": "Aún no hay nada que ver aquí." 186 + }, 187 + "login": { 188 + "signInWith": "Inicia sesión con tu", 189 + "handleSuffix": "handle", 190 + "handlePlaceholder": "handle.margin.cafe", 191 + "connecting": "Conectando…", 192 + "continue": "Continuar", 193 + "createAccount": "Crear cuenta nueva", 194 + "termsPrefix": "Al iniciar sesión, aceptas nuestros", 195 + "termsLink": "Términos de servicio", 196 + "termsAnd": "y", 197 + "privacyLink": "Política de privacidad" 198 + }, 199 + "signUp": { 200 + "title": "Crea tu cuenta", 201 + "subtitle": "Margin se adhiere al", 202 + "atProtocol": "AT Protocol", 203 + "subtitleSuffix": ". Elige un proveedor para alojar tu cuenta.", 204 + "customPdsTitle": "Usa un PDS personalizado", 205 + "customPdsSubtitle": "Introduce la dirección del PDS que aloja tu cuenta.", 206 + "pdsAddressLabel": "Dirección del PDS", 207 + "pdsAddressPlaceholder": "pds.example.com", 208 + "connecting": "Conectando…", 209 + "back": "Volver", 210 + "continue": "Continuar", 211 + "invite": "Invitar", 212 + "providerError": "No se pudo conectar con este proveedor. Por favor, inténtalo de nuevo.", 213 + "customPdsError": "No se pudo conectar con ese PDS. Revisa la dirección.", 214 + "providers": { 215 + "margin": { 216 + "description": "La forma más fácil de empezar", 217 + "name": "Margin" 218 + }, 219 + "bluesky": { 220 + "name": "Bluesky", 221 + "description": "La comunidad más grande y popular" 222 + }, 223 + "blacksky": { 224 + "name": "Blacksky", 225 + "description": "Para la cultura — un espacio seguro para usuarios y aliados" 226 + }, 227 + "eurosky": { 228 + "name": "Eurosky", 229 + "description": "Eurosky es tu hogar europeo en la Atmosphere" 230 + }, 231 + "selfhostedSocial": { 232 + "name": "selfhosted.social", 233 + "description": "Un hogar para creadores, entusiastas y curiosos" 234 + }, 235 + "northsky": { 236 + "name": "Northsky", 237 + "description": "Una cooperativa canadiense propiedad de los trabajadores" 238 + }, 239 + "tophhie": { 240 + "name": "Tophhie", 241 + "description": "Una comunidad acogedora y amable" 242 + }, 243 + "customPds": { 244 + "name": "Usa un PDS personalizado", 245 + "description": "¿Ya tienes un PDS? Introduce su dirección." 246 + } 247 + } 248 + }, 249 + "composer": { 250 + "newHighlight": "Nuevo resaltado", 251 + "newAnnotation": "Nueva anotación", 252 + "newNote": "Nueva nota", 253 + "saveHighlight": "Guardar resaltado", 254 + "postAnnotation": "Publicar anotación", 255 + "postNote": "Publicar nota", 256 + "highlightHint": "Guardando un pasaje sin comentario. Añade texto debajo para convertirlo en una anotación.", 257 + "addQuote": "+ Añadir una cita de la página", 258 + "quotePlaceholder": "Pega o escribe el texto que estás anotando…", 259 + "removeQuote": "Eliminar cita", 260 + "thoughtsPlaceholder": "Añade tus reflexiones sobre este pasaje…", 261 + "mindPlaceholder": "¿Qué tienes en mente?", 262 + "tagsPlaceholder": "Añadir etiquetas…", 263 + "contentWarning": "Advertencia de contenido", 264 + "contentWarningCount": "Advertencia de contenido ({{count}})", 265 + "cancel": "Cancelar", 266 + "failedToPost": "Error al publicar", 267 + "labels": { 268 + "sexual": "Sexual", 269 + "nudity": "Desnudez", 270 + "violence": "Violencia", 271 + "gore": "Gore", 272 + "spam": "Spam", 273 + "misleading": "Engañoso" 274 + } 275 + }, 276 + "card": { 277 + "addedTo": "Añadido a", 278 + "addedToLower": "añadido a", 279 + "and": "y", 280 + "communityBookmark": "Marcador de la comunidad", 281 + "openInSemble": "Abrir en Semble", 282 + "deleteConfirm": "¿Eliminar este elemento?", 283 + "hideContent": "Ocultar contenido", 284 + "show": "Mostrar", 285 + "edited": "(editado)", 286 + "annotate": "Anotar", 287 + "untitledBookmark": "Marcador sin título", 288 + "addNotePlaceholder": "Añade tu nota para convertir este resaltado en una anotación…", 289 + "addToCollectionTitle": "Añadir a la colección", 290 + "annotateTitle": "Anotar este resaltado", 291 + "editTitle": "Editar", 292 + "deleteTitle": "Eliminar", 293 + "report": "Reportar", 294 + "muteUser": "Silenciar a {{handle}}", 295 + "blockUser": "Bloquear a {{handle}}", 296 + "convertToAnnotation": "Convertir en anotación", 297 + "justNow": "hace un momento", 298 + "labelDescriptions": { 299 + "sexual": "Contenido sexual", 300 + "nudity": "Desnudez", 301 + "violence": "Violencia", 302 + "gore": "Contenido gráfico", 303 + "spam": "Spam", 304 + "misleading": "Engañoso" 377 305 } 306 + }, 307 + "profileHoverCard": { 308 + "viewProfile": "Ver perfil", 309 + "notFound": "Perfil no encontrado" 310 + }, 311 + "replyList": { 312 + "noReplies": "Aún no hay respuestas" 313 + }, 314 + "shareMenu": { 315 + "sembleIntegration": "Integración con Semble", 316 + "openOnSemble": "Abrir en Semble", 317 + "copySembleLink": "Copiar enlace de Semble", 318 + "copyLink": "Copiar enlace", 319 + "shareViaApp": "Compartir vía app", 320 + "copyUniversalLink": "Copiar enlace universal", 321 + "moreOptions": "Más opciones…", 322 + "copied": "¡Copiado!" 323 + }, 324 + "addToCollection": { 325 + "title": "Añadir a la colección", 326 + "loading": "Cargando colecciones…", 327 + "collectionNameLabel": "Nombre de la colección", 328 + "namePlaceholder": "Mi colección", 329 + "descriptionLabel": "Descripción (opcional)", 330 + "descriptionPlaceholder": "¿De qué trata esta colección?", 331 + "iconLabel": "Icono", 332 + "iconsTab": "Iconos", 333 + "emojisTab": "Emojis", 334 + "selected": "Seleccionado:", 335 + "back": "Volver", 336 + "create": "Crear", 337 + "creating": "Creando…", 338 + "newCollectionButton": "Nueva colección", 339 + "createNewDescription": "Crear una nueva colección", 340 + "none": "Aún no hay colecciones", 341 + "done": "Hecho", 342 + "failedLoad": "Error al cargar las colecciones", 343 + "failedAdd": "Error al añadir a la colección", 344 + "failedCreate": "Error al crear la colección" 345 + }, 346 + "editItem": { 347 + "editAnnotation": "Editar anotación", 348 + "editHighlight": "Editar resaltado", 349 + "editBookmark": "Editar marcador", 350 + "textLabel": "Texto", 351 + "textPlaceholder": "Escribe tu anotación…", 352 + "colorLabel": "Color", 353 + "tagsLabel": "Etiquetas", 354 + "tagPlaceholder": "Añadir una etiqueta…", 355 + "contentWarning": "Advertencia de contenido", 356 + "cancel": "Cancelar", 357 + "save": "Guardar", 358 + "saving": "Guardando…", 359 + "failedSave": "Error al guardar los cambios. Por favor, inténtalo de nuevo.", 360 + "titleLabel": "Título", 361 + "titlePlaceholder": "Título del marcador", 362 + "descriptionLabel": "Descripción", 363 + "descriptionPlaceholder": "Descripción opcional…" 364 + }, 365 + "editCollection": { 366 + "title": "Editar colección", 367 + "nameLabel": "Nombre de la colección", 368 + "namePlaceholder": "Mi colección", 369 + "descriptionLabel": "Descripción (opcional)", 370 + "descriptionPlaceholder": "¿De qué trata esta colección?", 371 + "iconLabel": "Icono", 372 + "iconsTab": "Iconos", 373 + "emojisTab": "Emojis", 374 + "selected": "Seleccionado:", 375 + "cancel": "Cancelar", 376 + "save": "Guardar cambios" 377 + } 378 378 }
+57
web/src/api/client.ts
··· 1362 1362 } 1363 1363 } 1364 1364 1365 + export interface BannedAccount { 1366 + did: string; 1367 + reason?: string; 1368 + bannedBy: string; 1369 + bannedAt: string; 1370 + profile?: { 1371 + did: string; 1372 + handle: string; 1373 + displayName?: string; 1374 + avatar?: string; 1375 + }; 1376 + } 1377 + 1378 + export async function adminBanAccount(params: { 1379 + did: string; 1380 + reason?: string; 1381 + }): Promise<boolean> { 1382 + try { 1383 + const res = await apiRequest("/api/moderation/admin/ban", { 1384 + method: "POST", 1385 + headers: { "Content-Type": "application/json" }, 1386 + body: JSON.stringify(params), 1387 + }); 1388 + return res.ok; 1389 + } catch (e) { 1390 + console.error("Failed to ban account:", e); 1391 + return false; 1392 + } 1393 + } 1394 + 1395 + export async function adminUnbanAccount(did: string): Promise<boolean> { 1396 + try { 1397 + const res = await apiRequest( 1398 + `/api/moderation/admin/ban?did=${encodeURIComponent(did)}`, 1399 + { method: "DELETE" }, 1400 + ); 1401 + return res.ok; 1402 + } catch (e) { 1403 + console.error("Failed to unban account:", e); 1404 + return false; 1405 + } 1406 + } 1407 + 1408 + export async function adminGetBannedAccounts(): Promise<{ 1409 + items: BannedAccount[]; 1410 + total: number; 1411 + }> { 1412 + try { 1413 + const res = await apiRequest("/api/moderation/admin/bans"); 1414 + if (!res.ok) return { items: [], total: 0 }; 1415 + return await res.json(); 1416 + } catch (e) { 1417 + console.error("Failed to fetch banned accounts:", e); 1418 + return { items: [], total: 0 }; 1419 + } 1420 + } 1421 + 1365 1422 export interface DocumentItem { 1366 1423 uri: string; 1367 1424 authorDid: string;
+7 -3
web/src/components/common/RichText.tsx
··· 61 61 } 62 62 }; 63 63 64 - const handleExternalClick = (e: React.MouseEvent, url: string) => { 64 + const handleExternalClick = ( 65 + e: React.MouseEvent, 66 + url: string, 67 + isBareUrl: boolean = false, 68 + ) => { 65 69 e.preventDefault(); 66 70 e.stopPropagation(); 67 71 ··· 78 82 return; 79 83 } 80 84 81 - if (preferences.disableExternalLinkWarning) { 85 + if (isBareUrl || preferences.disableExternalLinkWarning) { 82 86 window.open(url, "_blank", "noopener,noreferrer"); 83 87 return; 84 88 } ··· 110 114 target="_blank" 111 115 rel="noopener noreferrer" 112 116 className="text-primary-600 dark:text-primary-400 hover:underline break-all cursor-pointer" 113 - onClick={(e) => handleExternalClick(e, part.text)} 117 + onClick={(e) => handleExternalClick(e, part.text, true)} 114 118 > 115 119 {part.text} 116 120 </a>,
+43 -1
web/src/views/auth/Login.tsx
··· 1 1 import React, { useState, useEffect, useRef } from "react"; 2 - import { AtSign } from "lucide-react"; 2 + import { AtSign, ShieldOff } from "lucide-react"; 3 3 import { useTranslation } from "react-i18next"; 4 4 import "../../i18n"; 5 5 import SignUpModal from "../../components/modals/SignUpModal"; ··· 156 156 setLoading(false); 157 157 } 158 158 }; 159 + 160 + if (initialError === "banned") { 161 + return ( 162 + <div className="relative min-h-screen flex items-center justify-center bg-surface-100 dark:bg-surface-800 p-4 overflow-hidden"> 163 + <div className="pointer-events-none absolute inset-0 -z-0"> 164 + <div className="absolute top-1/4 left-1/2 -translate-x-1/2 h-96 w-96 rounded-full bg-red-200/30 dark:bg-red-900/20 blur-3xl" /> 165 + </div> 166 + <div className="relative w-full max-w-[440px] bg-white dark:bg-surface-900 rounded-2xl border border-surface-200/60 dark:border-surface-800 p-8 shadow-sm dark:shadow-none text-center"> 167 + <div className="flex justify-center mb-5"> 168 + <div className="w-14 h-14 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center"> 169 + <ShieldOff size={28} className="text-red-500" /> 170 + </div> 171 + </div> 172 + <h1 className="text-xl font-bold font-display text-surface-900 dark:text-white mb-2"> 173 + {t("login.bannedTitle")} 174 + </h1> 175 + <p className="text-sm text-surface-500 dark:text-surface-400 mb-1 leading-relaxed"> 176 + {t("login.bannedMessage")} 177 + </p> 178 + <p className="text-sm text-surface-500 dark:text-surface-400 mb-6 leading-relaxed"> 179 + {t("login.bannedAppeal")}{" "} 180 + <a 181 + href="mailto:hello@margin.at" 182 + className="text-[#027bff] hover:underline font-medium" 183 + > 184 + hello@margin.at 185 + </a> 186 + . 187 + </p> 188 + <button 189 + onClick={async () => { 190 + await fetch("/auth/logout", { method: "POST" }).catch(() => {}); 191 + window.location.href = "/login"; 192 + }} 193 + className="w-full py-3 bg-surface-100 dark:bg-surface-800 hover:bg-surface-200 dark:hover:bg-surface-700 text-surface-700 dark:text-surface-300 rounded-xl font-semibold transition-all text-sm" 194 + > 195 + {t("login.bannedSignOut")} 196 + </button> 197 + </div> 198 + </div> 199 + ); 200 + } 159 201 160 202 return ( 161 203 <div className="relative min-h-screen flex items-center justify-center bg-surface-100 dark:bg-surface-800 p-4 overflow-hidden">
+177 -2
web/src/views/core/AdminModeration.tsx
··· 9 9 adminCreateLabel, 10 10 adminDeleteLabel, 11 11 adminGetLabels, 12 + adminBanAccount, 13 + adminUnbanAccount, 14 + adminGetBannedAccounts, 15 + type BannedAccount, 12 16 } from "../../api/client"; 13 17 import type { ModerationReport, HydratedLabel } from "../../types"; 14 18 import { ··· 24 28 Plus, 25 29 Trash2, 26 30 EyeOff, 31 + UserX, 32 + UserCheck, 27 33 } from "lucide-react"; 28 34 import { Avatar, EmptyState, Skeleton, Button } from "../../components/ui"; 29 35 ··· 57 63 "misleading", 58 64 ]; 59 65 60 - type Tab = "reports" | "labels" | "actions"; 66 + type Tab = "reports" | "labels" | "actions" | "bans"; 61 67 62 68 export default function AdminModeration() { 63 69 const { t } = useTranslation(); ··· 81 87 const [labelSubmitting, setLabelSubmitting] = useState(false); 82 88 const [labelSuccess, setLabelSuccess] = useState(false); 83 89 90 + const [bans, setBans] = useState<BannedAccount[]>([]); 91 + const [banDid, setBanDid] = useState(""); 92 + const [banReason, setBanReason] = useState(""); 93 + const [banSubmitting, setBanSubmitting] = useState(false); 94 + const [banSuccess, setBanSuccess] = useState(false); 95 + const [unbanLoading, setUnbanLoading] = useState<string | null>(null); 96 + 84 97 const loadReports = async (status: string) => { 85 98 const data = await getAdminReports(status || undefined); 86 99 setReports(data.items); ··· 93 106 setLabels(data.items || []); 94 107 }; 95 108 109 + const loadBans = async () => { 110 + const data = await adminGetBannedAccounts(); 111 + setBans(data.items || []); 112 + }; 113 + 96 114 useEffect(() => { 97 115 const init = async () => { 98 116 const admin = await checkAdminAccess(); ··· 106 124 const handleTabChange = async (tab: Tab) => { 107 125 setActiveTab(tab); 108 126 if (tab === "labels") await loadLabels(); 127 + if (tab === "bans") await loadBans(); 109 128 }; 110 129 111 130 const handleFilterChange = async (status: string) => { ··· 123 142 setActionLoading(null); 124 143 }; 125 144 145 + const handleBanFromReport = async (did: string, reportId: number) => { 146 + setActionLoading(reportId); 147 + await adminBanAccount({ did }); 148 + setActionLoading(null); 149 + }; 150 + 151 + const handleBanAccount = async () => { 152 + if (!banDid.trim()) return; 153 + setBanSubmitting(true); 154 + const success = await adminBanAccount({ 155 + did: banDid.trim(), 156 + reason: banReason.trim() || undefined, 157 + }); 158 + if (success) { 159 + setBanDid(""); 160 + setBanReason(""); 161 + setBanSuccess(true); 162 + setTimeout(() => setBanSuccess(false), 2000); 163 + await loadBans(); 164 + } 165 + setBanSubmitting(false); 166 + }; 167 + 168 + const handleUnban = async (did: string) => { 169 + setUnbanLoading(did); 170 + const success = await adminUnbanAccount(did); 171 + if (success) setBans((prev) => prev.filter((b) => b.did !== did)); 172 + setUnbanLoading(null); 173 + }; 174 + 126 175 const handleCreateLabel = async () => { 127 176 if (!labelVal || (!labelSrc && !labelUri)) return; 128 177 setLabelSubmitting(true); ··· 207 256 id: "labels" as Tab, 208 257 label: t("adminModeration.tabs.labels"), 209 258 icon: <Tag size={15} />, 259 + }, 260 + { 261 + id: "bans" as Tab, 262 + label: "Bans", 263 + icon: <UserX size={15} />, 210 264 }, 211 265 ].map((tab) => ( 212 266 <button ··· 360 414 )} 361 415 362 416 {report.status === "pending" && ( 363 - <div className="flex items-center gap-2 pt-2"> 417 + <div className="flex flex-wrap items-center gap-2 pt-2"> 364 418 <Button 365 419 size="sm" 366 420 variant="secondary" ··· 390 444 > 391 445 {t("adminModeration.reports.takedown")} 392 446 </Button> 447 + <Button 448 + size="sm" 449 + onClick={() => 450 + handleBanFromReport(report.subject.did, report.id) 451 + } 452 + loading={actionLoading === report.id} 453 + icon={<UserX size={14} />} 454 + className="!bg-gray-800 hover:!bg-gray-900 !text-white dark:!bg-gray-700 dark:hover:!bg-gray-600" 455 + > 456 + Ban user 457 + </Button> 393 458 </div> 394 459 )} 395 460 </div> ··· 551 616 ))} 552 617 </div> 553 618 )} 619 + </div> 620 + )} 621 + 622 + {activeTab === "bans" && ( 623 + <div className="space-y-6"> 624 + <div className="card p-5"> 625 + <h3 className="text-base font-semibold text-surface-900 dark:text-white mb-1 flex items-center gap-2"> 626 + <UserX size={16} className="text-red-500" /> 627 + Ban account 628 + </h3> 629 + <p className="text-sm text-surface-500 dark:text-surface-400 mb-4"> 630 + Banned users cannot sign in and their content is hidden everywhere 631 + on Margin. 632 + </p> 633 + 634 + <div className="space-y-3"> 635 + <div> 636 + <label className="block text-xs font-medium text-surface-600 dark:text-surface-400 mb-1.5"> 637 + Account DID 638 + </label> 639 + <input 640 + type="text" 641 + value={banDid} 642 + onChange={(e) => setBanDid(e.target.value)} 643 + placeholder="did:plc:..." 644 + className="w-full px-3 py-2 text-sm rounded-lg border border-surface-200 dark:border-surface-700 bg-white dark:bg-surface-800 text-surface-900 dark:text-white placeholder:text-surface-400 focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500" 645 + /> 646 + </div> 647 + 648 + <div> 649 + <label className="block text-xs font-medium text-surface-600 dark:text-surface-400 mb-1.5"> 650 + Reason <span className="text-surface-400">(optional)</span> 651 + </label> 652 + <input 653 + type="text" 654 + value={banReason} 655 + onChange={(e) => setBanReason(e.target.value)} 656 + placeholder="Reason for ban..." 657 + className="w-full px-3 py-2 text-sm rounded-lg border border-surface-200 dark:border-surface-700 bg-white dark:bg-surface-800 text-surface-900 dark:text-white placeholder:text-surface-400 focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500" 658 + /> 659 + </div> 660 + 661 + <div className="flex items-center gap-3 pt-1"> 662 + <Button 663 + onClick={handleBanAccount} 664 + loading={banSubmitting} 665 + disabled={!banDid.trim()} 666 + icon={<UserX size={14} />} 667 + size="sm" 668 + className="!bg-red-600 hover:!bg-red-700 !text-white" 669 + > 670 + Ban account 671 + </Button> 672 + {banSuccess && ( 673 + <span className="text-sm text-green-600 dark:text-green-400 flex items-center gap-1.5"> 674 + <CheckCircle size={14} /> Account banned 675 + </span> 676 + )} 677 + </div> 678 + </div> 679 + </div> 680 + 681 + <div> 682 + {bans.length === 0 ? ( 683 + <EmptyState 684 + icon={<UserCheck size={40} />} 685 + title="No banned accounts" 686 + message="Banned accounts will appear here." 687 + /> 688 + ) : ( 689 + <div className="space-y-2"> 690 + {bans.map((ban) => ( 691 + <div 692 + key={ban.did} 693 + className="card p-4 flex items-center gap-4" 694 + > 695 + <Avatar 696 + did={ban.did} 697 + avatar={ban.profile?.avatar} 698 + size="sm" 699 + /> 700 + <div className="flex-1 min-w-0"> 701 + <div className="flex items-center gap-2 mb-0.5"> 702 + <span className="font-medium text-surface-900 dark:text-white text-sm truncate"> 703 + {ban.profile?.displayName || 704 + (ban.profile?.handle && `@${ban.profile.handle}`) || 705 + ban.did} 706 + </span> 707 + <span className="text-xs px-2 py-0.5 rounded-full font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300"> 708 + banned 709 + </span> 710 + </div> 711 + <p className="text-xs text-surface-500 dark:text-surface-400 truncate"> 712 + {ban.reason ? `${ban.reason} · ` : ""} 713 + {new Date(ban.bannedAt).toLocaleDateString()} 714 + </p> 715 + </div> 716 + <button 717 + onClick={() => handleUnban(ban.did)} 718 + disabled={unbanLoading === ban.did} 719 + className="p-2 rounded-lg text-surface-400 hover:text-green-600 hover:bg-green-50 dark:hover:bg-green-900/20 transition-colors" 720 + title="Unban" 721 + > 722 + <UserCheck size={14} /> 723 + </button> 724 + </div> 725 + ))} 726 + </div> 727 + )} 728 + </div> 554 729 </div> 555 730 )} 556 731 </div>