(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol.
0
fork

Configure Feed

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

better moderation

scanash00 a1d6cca9 c1d1bd21

+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>