From a1d6cca937de98fafda8274d96e5427bf6791869 Mon Sep 17 00:00:00 2001 From: scanash00 Date: Sat, 25 Apr 2026 11:49:33 +0000 Subject: [PATCH] better moderation --- backend/internal/api/handler.go | 3 +++ backend/internal/api/moderation.go | 103 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ backend/internal/db/queries_annotations.go | 3 +++ backend/internal/db/queries_highlights.go | 3 +++ backend/internal/db/queries_moderation.go | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ backend/internal/db/queries_sessions.go | 19 ++++++++++++++++--- backend/internal/oauth/handler.go | 4 ++++ backend/internal/service/feed.go | 16 ++++++++++++++++ web/src/api/client.ts | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ backend/internal/db/migrations/00007_banned_accounts.sql | 10 ++++++++++ backend/internal/db/migrations/00008_taken_down_uris.sql | 8 ++++++++ web/public/locales/en/translation.json | 1486 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- web/public/locales/es/translation.json | 742 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- web/src/components/common/RichText.tsx | 10 +++++++--- web/src/views/auth/Login.tsx | 44 +++++++++++++++++++++++++++++++++++++++++++- web/src/views/core/AdminModeration.tsx | 179 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- 16 file(s) changed, 1643 insertion(s)(+), 1121 deletion(s)(-) diff --git a/backend/internal/api/handler.go b/backend/internal/api/handler.go --- a/backend/internal/api/handler.go +++ b/backend/internal/api/handler.go @@ -251,6 +251,9 @@ r.Post("/moderation/admin/label", h.moderation.AdminCreateLabel) r.Delete("/moderation/admin/label", h.moderation.AdminDeleteLabel) r.Get("/moderation/admin/labels", h.moderation.AdminGetLabels) + r.Post("/moderation/admin/ban", h.moderation.AdminBanAccount) + r.Delete("/moderation/admin/ban", h.moderation.AdminUnbanAccount) + r.Get("/moderation/admin/bans", h.moderation.AdminGetBannedAccounts) r.Get("/moderation/labeler", h.moderation.GetLabelerInfo) // Admin diff --git a/backend/internal/api/moderation.go b/backend/internal/api/moderation.go --- a/backend/internal/api/moderation.go +++ b/backend/internal/api/moderation.go @@ -456,6 +456,8 @@ } func (m *ModerationHandler) deleteContent(uri string) { + m.db.MarkTakenDown(uri) + m.db.Exec("DELETE FROM notes WHERE uri = $1", uri) m.db.Exec("DELETE FROM annotations WHERE uri = $1", uri) m.db.Exec("DELETE FROM highlights WHERE uri = $1", uri) m.db.Exec("DELETE FROM bookmarks WHERE uri = $1", uri) @@ -653,4 +655,105 @@ "name": "Margin Moderation", "labels": labels, }) +} + +func (m *ModerationHandler) AdminBanAccount(w http.ResponseWriter, r *http.Request) { + session, err := m.refresher.GetSessionWithAutoRefresh(r) + if err != nil { + WriteUnauthorized(w, "Unauthorized") + return + } + if !config.Get().IsAdmin(session.DID) { + WriteForbidden(w, "Forbidden") + return + } + + var req struct { + DID string `json:"did"` + Reason *string `json:"reason,omitempty"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.DID == "" { + WriteBadRequest(w, "did is required") + return + } + + if err := m.db.BanAccount(req.DID, session.DID, req.Reason); err != nil { + logger.Error("Failed to ban account: %v", err) + WriteInternalError(w, "Failed to ban account") + return + } + + m.db.DeleteSessionsByDID(req.DID) + + WriteSuccess(w, map[string]string{"status": "ok"}) +} + +func (m *ModerationHandler) AdminUnbanAccount(w http.ResponseWriter, r *http.Request) { + session, err := m.refresher.GetSessionWithAutoRefresh(r) + if err != nil { + WriteUnauthorized(w, "Unauthorized") + return + } + if !config.Get().IsAdmin(session.DID) { + WriteForbidden(w, "Forbidden") + return + } + + did := r.URL.Query().Get("did") + if did == "" { + WriteBadRequest(w, "did is required") + return + } + + if err := m.db.UnbanAccount(did); err != nil { + logger.Error("Failed to unban account: %v", err) + WriteInternalError(w, "Failed to unban account") + return + } + + WriteSuccess(w, map[string]string{"status": "ok"}) +} + +func (m *ModerationHandler) AdminGetBannedAccounts(w http.ResponseWriter, r *http.Request) { + session, err := m.refresher.GetSessionWithAutoRefresh(r) + if err != nil { + WriteUnauthorized(w, "Unauthorized") + return + } + if !config.Get().IsAdmin(session.DID) { + WriteForbidden(w, "Forbidden") + return + } + + accounts, err := m.db.GetBannedAccounts() + if err != nil { + WriteInternalError(w, "Failed to get banned accounts") + return + } + + if accounts == nil { + accounts = []db.BannedAccount{} + } + + dids := make([]string, len(accounts)) + for i, a := range accounts { + dids[i] = a.DID + } + profileMap := fetchProfilesForDIDs(m.db, dids) + + type BannedEntry struct { + db.BannedAccount + Profile *Author `json:"profile,omitempty"` + } + entries := make([]BannedEntry, len(accounts)) + for i, a := range accounts { + p, ok := profileMap[a.DID] + var profile *Author + if ok { + profile = &p + } + entries[i] = BannedEntry{BannedAccount: a, Profile: profile} + } + + WriteSuccess(w, map[string]interface{}{"items": entries, "total": len(entries)}) } diff --git a/backend/internal/db/queries_annotations.go b/backend/internal/db/queries_annotations.go --- a/backend/internal/db/queries_annotations.go +++ b/backend/internal/db/queries_annotations.go @@ -5,6 +5,9 @@ ) func (db *DB) CreateAnnotation(a *Annotation) error { + if taken, _ := db.IsTakenDown(a.URI); taken { + return nil + } _, err := db.Exec(` 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) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) diff --git a/backend/internal/db/queries_highlights.go b/backend/internal/db/queries_highlights.go --- a/backend/internal/db/queries_highlights.go +++ b/backend/internal/db/queries_highlights.go @@ -5,6 +5,9 @@ ) func (db *DB) CreateHighlight(h *Highlight) error { + if taken, _ := db.IsTakenDown(h.URI); taken { + return nil + } _, err := db.Exec(` INSERT INTO highlights (uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) diff --git a/backend/internal/db/queries_moderation.go b/backend/internal/db/queries_moderation.go --- a/backend/internal/db/queries_moderation.go +++ b/backend/internal/db/queries_moderation.go @@ -400,3 +400,80 @@ func itoa(i int) string { return strings.Repeat("", 0) + fmt.Sprintf("%d", i) } + +func (db *DB) MarkTakenDown(uri string) error { + _, err := db.Exec(` + INSERT INTO taken_down_uris (uri, taken_down_at) VALUES ($1, $2) + ON CONFLICT(uri) DO NOTHING + `, uri, time.Now()) + return err +} + +func (db *DB) IsTakenDown(uri string) (bool, error) { + var exists bool + err := db.QueryRow(`SELECT EXISTS(SELECT 1 FROM taken_down_uris WHERE uri = $1)`, uri).Scan(&exists) + return exists, err +} + +type BannedAccount struct { + DID string `json:"did"` + Reason *string `json:"reason,omitempty"` + BannedBy string `json:"bannedBy"` + BannedAt time.Time `json:"bannedAt"` +} + +func (db *DB) BanAccount(did, bannedBy string, reason *string) error { + _, err := db.Exec(` + INSERT INTO banned_accounts (did, reason, banned_by, banned_at) + VALUES ($1, $2, $3, $4) + ON CONFLICT(did) DO UPDATE SET reason = EXCLUDED.reason, banned_by = EXCLUDED.banned_by, banned_at = EXCLUDED.banned_at + `, did, reason, bannedBy, time.Now()) + return err +} + +func (db *DB) UnbanAccount(did string) error { + _, err := db.Exec(`DELETE FROM banned_accounts WHERE did = $1`, did) + return err +} + +func (db *DB) IsBanned(did string) (bool, error) { + var exists bool + err := db.QueryRow(`SELECT EXISTS(SELECT 1 FROM banned_accounts WHERE did = $1)`, did).Scan(&exists) + return exists, err +} + +func (db *DB) GetBannedAccounts() ([]BannedAccount, error) { + rows, err := db.Query(`SELECT did, reason, banned_by, banned_at FROM banned_accounts ORDER BY banned_at DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + + var accounts []BannedAccount + for rows.Next() { + var a BannedAccount + if err := rows.Scan(&a.DID, &a.Reason, &a.BannedBy, &a.BannedAt); err != nil { + continue + } + accounts = append(accounts, a) + } + return accounts, nil +} + +func (db *DB) GetBannedDIDs() ([]string, error) { + rows, err := db.Query(`SELECT did FROM banned_accounts`) + if err != nil { + return nil, err + } + defer rows.Close() + + var dids []string + for rows.Next() { + var did string + if err := rows.Scan(&did); err != nil { + continue + } + dids = append(dids, did) + } + return dids, nil +} diff --git a/backend/internal/db/queries_sessions.go b/backend/internal/db/queries_sessions.go --- a/backend/internal/db/queries_sessions.go +++ b/backend/internal/db/queries_sessions.go @@ -1,11 +1,19 @@ package db import ( + "errors" "time" ) +var ErrAccountBanned = errors.New("account is banned") + func (db *DB) SaveSession(id, did, handle, accessToken, refreshToken, dpopKey string, expiresAt time.Time) error { - _, err := db.Exec(` + banned, err := db.IsBanned(did) + if err == nil && banned { + return ErrAccountBanned + } + + _, err = db.Exec(` INSERT INTO sessions (id, did, handle, access_token, refresh_token, dpop_key, created_at, expires_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT(id) DO UPDATE SET @@ -36,11 +44,16 @@ return err } +func (db *DB) DeleteSessionsByDID(did string) error { + _, err := db.Exec(`DELETE FROM sessions WHERE did = $1`, did) + return err +} + func (db *DB) CountSessionsByDID(did string) (int, error) { var n int err := db.QueryRow( - `SELECT COUNT(*) FROM sessions WHERE did = $1 AND expires_at > $2`, - did, time.Now(), + `SELECT COUNT(*) FROM sessions WHERE did = $1`, + did, ).Scan(&n) return n, err } diff --git a/backend/internal/oauth/handler.go b/backend/internal/oauth/handler.go --- a/backend/internal/oauth/handler.go +++ b/backend/internal/oauth/handler.go @@ -456,6 +456,10 @@ expiresAt, ) if err != nil { + if err == db.ErrAccountBanned { + http.Redirect(w, r, "/login?error=banned", http.StatusFound) + return + } http.Error(w, "Failed to save session", http.StatusInternalServerError) return } diff --git a/backend/internal/service/feed.go b/backend/internal/service/feed.go --- a/backend/internal/service/feed.go +++ b/backend/internal/service/feed.go @@ -27,6 +27,7 @@ hydration *HydrationService database interface { GetAllHiddenDIDs(actorDID string) (map[string]bool, error) + GetBannedDIDs() ([]string, error) } } @@ -35,6 +36,7 @@ hydration *HydrationService, db interface { GetAllHiddenDIDs(actorDID string) (map[string]bool, error) + GetBannedDIDs() ([]string, error) }, ) *FeedService { return &FeedService{ @@ -62,6 +64,20 @@ notes, err := s.notes.List(ctx, filter) if err != nil { return nil, err + } + + if bannedDIDs, err := s.database.GetBannedDIDs(); err == nil && len(bannedDIDs) > 0 { + banned := make(map[string]bool, len(bannedDIDs)) + for _, did := range bannedDIDs { + banned[did] = true + } + filtered := notes[:0] + for _, n := range notes { + if !banned[n.AuthorDID] { + filtered = append(filtered, n) + } + } + notes = filtered } if req.ViewerDID != "" { diff --git a/web/src/api/client.ts b/web/src/api/client.ts --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1362,6 +1362,63 @@ } } +export interface BannedAccount { + did: string; + reason?: string; + bannedBy: string; + bannedAt: string; + profile?: { + did: string; + handle: string; + displayName?: string; + avatar?: string; + }; +} + +export async function adminBanAccount(params: { + did: string; + reason?: string; +}): Promise { + try { + const res = await apiRequest("/api/moderation/admin/ban", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(params), + }); + return res.ok; + } catch (e) { + console.error("Failed to ban account:", e); + return false; + } +} + +export async function adminUnbanAccount(did: string): Promise { + try { + const res = await apiRequest( + `/api/moderation/admin/ban?did=${encodeURIComponent(did)}`, + { method: "DELETE" }, + ); + return res.ok; + } catch (e) { + console.error("Failed to unban account:", e); + return false; + } +} + +export async function adminGetBannedAccounts(): Promise<{ + items: BannedAccount[]; + total: number; +}> { + try { + const res = await apiRequest("/api/moderation/admin/bans"); + if (!res.ok) return { items: [], total: 0 }; + return await res.json(); + } catch (e) { + console.error("Failed to fetch banned accounts:", e); + return { items: [], total: 0 }; + } +} + export interface DocumentItem { uri: string; authorDid: string; diff --git a/backend/internal/db/migrations/00007_banned_accounts.sql b/backend/internal/db/migrations/00007_banned_accounts.sql new file mode 100644 --- /dev/null +++ b/backend/internal/db/migrations/00007_banned_accounts.sql @@ -0,0 +1,10 @@ +-- +goose Up +CREATE TABLE IF NOT EXISTS banned_accounts ( + did TEXT PRIMARY KEY, + reason TEXT, + banned_by TEXT NOT NULL, + banned_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- +goose Down +DROP TABLE IF EXISTS banned_accounts; diff --git a/backend/internal/db/migrations/00008_taken_down_uris.sql b/backend/internal/db/migrations/00008_taken_down_uris.sql new file mode 100644 --- /dev/null +++ b/backend/internal/db/migrations/00008_taken_down_uris.sql @@ -0,0 +1,8 @@ +-- +goose Up +CREATE TABLE IF NOT EXISTS taken_down_uris ( + uri TEXT PRIMARY KEY, + taken_down_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- +goose Down +DROP TABLE IF EXISTS taken_down_uris; diff --git a/web/public/locales/en/translation.json b/web/public/locales/en/translation.json --- a/web/public/locales/en/translation.json +++ b/web/public/locales/en/translation.json @@ -1,763 +1,767 @@ { - "appTitle": "Margin", - "nav": { - "feed": "Feed", - "discover": "Discover", - "annotations": "Annotations", - "highlights": "Highlights", - "bookmarks": "Bookmarks", - "collections": "Collections", - "activity": "Activity", - "settings": "Settings", - "new": "New", - "signIn": "Sign in", - "logOut": "Log out", - "themeLight": "Light", - "themeDark": "Dark", - "themeSystem": "System" + "appTitle": "Margin", + "nav": { + "feed": "Feed", + "discover": "Discover", + "annotations": "Annotations", + "highlights": "Highlights", + "bookmarks": "Bookmarks", + "collections": "Collections", + "activity": "Activity", + "settings": "Settings", + "new": "New", + "signIn": "Sign in", + "logOut": "Log out", + "themeLight": "Light", + "themeDark": "Dark", + "themeSystem": "System" + }, + "pageTitles": { + "home": "Home — Margin", + "bookmarks": "Bookmarks — Margin", + "highlights": "Highlights — Margin", + "annotations": "Annotations — Margin", + "discover": "Discover — Margin", + "search": "Search — Margin", + "notifications": "Notifications — Margin", + "new": "New Annotation — Margin", + "settings": "Settings — Margin", + "collections": "Collections — Margin", + "admin": "Admin — Margin" + }, + "sidebar": { + "getExtension": "Get the Extension", + "extensionTagline": "Highlight, annotate, and bookmark from any page.", + "downloadForFirefox": "Download for Firefox", + "downloadForEdge": "Download for Edge", + "downloadForChrome": "Download for Chrome", + "trending": "Trending", + "nothingTrending": "Nothing trending right now.", + "searchPlaceholder": "Search people, tags, URLs…", + "copyright": "© 2026 Padding Labs LLC", + "postCount_one": "{{count}} post", + "postCount_other": "{{count}} posts" + }, + "mobileNav": { + "iosShortcut": "iOS Shortcut" + }, + "feed": { + "welcome": "Welcome to Margin", + "welcomeTagline": "A quiet place to annotate, highlight, and save what you read on the web.", + "getStarted": "Get started", + "learnMore": "Learn more", + "tabs": { + "recent": "Recent", + "popular": "Popular", + "shelved": "Shelved", + "margin": "Margin", + "semble": "Semble" }, - "pageTitles": { - "home": "Home — Margin", - "bookmarks": "Bookmarks — Margin", - "highlights": "Highlights — Margin", - "annotations": "Annotations — Margin", - "discover": "Discover — Margin", - "search": "Search — Margin", - "notifications": "Notifications — Margin", - "new": "New Annotation — Margin", - "settings": "Settings — Margin", - "collections": "Collections — Margin", - "admin": "Admin — Margin" + "filters": { + "all": "All", + "annotations": "Annotations", + "highlights": "Highlights", + "bookmarks": "Bookmarks" }, - "sidebar": { - "getExtension": "Get the Extension", - "extensionTagline": "Highlight, annotate, and bookmark from any page.", - "downloadForFirefox": "Download for Firefox", - "downloadForEdge": "Download for Edge", - "downloadForChrome": "Download for Chrome", - "trending": "Trending", - "nothingTrending": "Nothing trending right now.", - "searchPlaceholder": "Search people, tags, URLs…", - "copyright": "© 2026 Padding Labs LLC", - "postCount_one": "{{count}} post", - "postCount_other": "{{count}} posts" + "itemsWithTag": "Items with tag:", + "clearFilter": "Clear filter", + "everyone": "Everyone", + "mine": "Mine", + "defaultEmptyMessage": "Nothing here yet — annotations from you and people you follow will show up here.", + "nothingHereYet": "Nothing here yet", + "loading": "Loading…" + }, + "discover": { + "tabs": { + "new": "New", + "popular": "Popular", + "forYou": "For You" }, - "mobileNav": { - "iosShortcut": "iOS Shortcut" + "comingSoon": "Coming soon", + "forYouNotAvailable": "Personalized recommendations aren't available on this server yet.", + "noDocumentsYet": "No documents have been discovered yet. Check back soon!", + "startAnnotating": "Start annotating and highlighting to get personalized recommendations.", + "loadMore": "Load more" + }, + "search": { + "placeholder": "Search annotations, highlights, bookmarks…", + "noResults": "No results found", + "noResultsMessage": "Nothing matched \"{{query}}\". Try different keywords.", + "emptyTitle": "Search your library", + "emptyMessage": "Find annotations, highlights, and bookmarks by keyword, URL, or tag.", + "filters": { + "all": "All", + "annotations": "Annotations", + "highlights": "Highlights", + "bookmarks": "Bookmarks", + "mine": "Mine" }, - "feed": { - "welcome": "Welcome to Margin", - "welcomeTagline": "A quiet place to annotate, highlight, and save what you read on the web.", - "getStarted": "Get started", - "learnMore": "Learn more", - "tabs": { - "recent": "Recent", - "popular": "Popular", - "shelved": "Shelved", - "margin": "Margin", - "semble": "Semble" - }, - "filters": { - "all": "All", - "annotations": "Annotations", - "highlights": "Highlights", - "bookmarks": "Bookmarks" - }, - "itemsWithTag": "Items with tag:", - "clearFilter": "Clear filter", - "everyone": "Everyone", - "mine": "Mine", - "defaultEmptyMessage": "Nothing here yet — annotations from you and people you follow will show up here.", - "nothingHereYet": "Nothing here yet", - "loading": "Loading…" + "resultCount": "{{count}}{{hasMore}} results for \"{{query}}\"", + "loadMore": "Load more" + }, + "notifications": { + "title": "Activity", + "noActivity": "No activity yet", + "noActivityMessage": "Interactions with your content will appear here.", + "likedAnnotation": "liked your annotation", + "likedHighlight": "liked your highlight", + "likedBookmark": "liked your bookmark", + "likedReply": "liked your reply", + "likedPost": "liked your post", + "repliedToReply": "replied to your reply", + "repliedToAnnotation": "replied to your annotation", + "mentionedInAnnotation": "mentioned you in an annotation", + "followedYou": "followed you", + "highlightedPage": "highlighted your page", + "inReplyTo": "in reply to", + "aReply": "a reply", + "anAnnotation": "an annotation" + }, + "collections": { + "title": "Collections", + "subtitle": "Organize your annotations and highlights", + "none": "No collections yet", + "noneMessage": "Create a collection to organize your highlights and annotations.", + "createButton": "Create collection", + "newTitle": "New Collection", + "editTitle": "Edit Collection", + "namePlaceholder": "My Collection", + "namePlaceholderEdit": "Collection name", + "nameLabel": "Name", + "iconLabel": "Icon", + "iconsTab": "Icons", + "emojisTab": "Emojis", + "selectedIcon": "Selected:", + "descriptionLabel": "Description (optional)", + "descriptionPlaceholder": "What's this collection for?", + "descriptionPlaceholderEdit": "What's this collection about?", + "cancel": "Cancel", + "create": "Create Collection", + "creating": "Creating…", + "save": "Save Changes", + "saving": "Saving…", + "deleteConfirm": "Delete this collection?", + "failedUpdate": "Failed to update collection", + "errorUpdating": "An error occurred while updating", + "itemCount_one": "{{count}} item", + "itemCount_other": "{{count}} items" + }, + "collectionDetail": { + "backLink": "Collections", + "by": "by", + "edit": "Edit collection", + "delete": "Delete collection", + "removeFromCollection": "Remove from collection", + "viewInSemble": "View in Semble", + "empty": "Collection is empty", + "notFound": "Collection not found", + "failedToLoad": "Failed to load collection", + "deleteConfirm": "Delete this collection?", + "removeConfirm": "Remove from collection?" + }, + "profile": { + "notFound": "User not found", + "notFoundMessage": "This profile doesn't exist or couldn't be loaded.", + "edit": "Edit", + "viewInBluesky": "View profile in Bluesky", + "unblock": "Unblock @{{handle}}", + "block": "Block @{{handle}}", + "unmute": "Unmute @{{handle}}", + "mute": "Mute @{{handle}}", + "report": "Report", + "accountLabeled": "Account labeled: {{description}}", + "labelApplied": "This label was applied by a moderation service you subscribe to.", + "show": "Show", + "hide": "Hide", + "blockedBanner": "You have blocked @{{handle}}", + "blockedMessage": "Their content is hidden from your feeds.", + "mutedBanner": "You have muted @{{handle}}", + "mutedMessage": "Their content is hidden from your feeds.", + "blockedByBanner": "@{{handle}} has blocked you. You cannot interact with their content.", + "unblock_action": "Unblock", + "unmute_action": "Unmute", + "emptyCollectionsOwn": "You haven't created any collections yet.", + "emptyCollectionsOther": "No collections", + "itemCount_one": "{{count}} item", + "itemCount_other": "{{count}} items", + "emptyTabOwn": "Your {{tab}} will show up here.", + "emptyTabOther": "Nothing to see here yet." + }, + "login": { + "signInWith": "Sign in with your", + "handleSuffix": "handle", + "handlePlaceholder": "handle.margin.cafe", + "connecting": "Connecting…", + "continue": "Continue", + "createAccount": "Create New Account", + "termsPrefix": "By signing in, you agree to our", + "termsLink": "Terms of Service", + "termsAnd": "and", + "privacyLink": "Privacy Policy", + "bannedTitle": "Your account has been suspended", + "bannedMessage": "This account has been suspended from Margin and is no longer able to sign in.", + "bannedAppeal": "If you believe this is a mistake, you can appeal by emailing", + "bannedSignOut": "Sign out" + }, + "signUp": { + "title": "Create your account", + "subtitle": "Margin adheres to the", + "atProtocol": "AT Protocol", + "subtitleSuffix": ". Choose a provider to host your account.", + "customPdsTitle": "Use a custom PDS", + "customPdsSubtitle": "Enter the address of the PDS hosting your account.", + "pdsAddressLabel": "PDS address", + "pdsAddressPlaceholder": "pds.example.com", + "connecting": "Connecting…", + "back": "Back", + "continue": "Continue", + "invite": "Invite", + "providerError": "Could not connect to this provider. Please try again.", + "customPdsError": "Couldn't connect to that PDS. Double-check the address.", + "providers": { + "margin": { + "name": "Margin", + "description": "The easiest way to get started" + }, + "bluesky": { + "name": "Bluesky", + "description": "The largest and most popular community" + }, + "blacksky": { + "name": "Blacksky", + "description": "For the Culture — a safe space for users and allies" + }, + "eurosky": { + "name": "Eurosky", + "description": "Eurosky is your European home on the Atmosphere" + }, + "selfhostedSocial": { + "name": "selfhosted.social", + "description": "A home for builders, tinkerers, and the curious" + }, + "northsky": { + "name": "Northsky", + "description": "A Canadian worker-owned cooperative" + }, + "tophhie": { + "name": "Tophhie", + "description": "A welcoming and friendly community" + }, + "customPds": { + "name": "Use a custom PDS", + "description": "Already have a PDS? Enter its address." + } + } + }, + "composer": { + "newHighlight": "New highlight", + "newAnnotation": "New annotation", + "newNote": "New note", + "saveHighlight": "Save highlight", + "postAnnotation": "Post annotation", + "postNote": "Post note", + "highlightHint": "Saving a passage without a comment. Add text below to turn it into an annotation.", + "addQuote": "+ Add a quote from the page", + "quotePlaceholder": "Paste or type the text you're annotating…", + "removeQuote": "Remove Quote", + "thoughtsPlaceholder": "Add your thoughts on this passage…", + "mindPlaceholder": "What's on your mind?", + "tagsPlaceholder": "Add tags…", + "contentWarning": "Content Warning", + "contentWarningCount": "Content Warning ({{count}})", + "cancel": "Cancel", + "failedToPost": "Failed to post", + "labels": { + "sexual": "Sexual", + "nudity": "Nudity", + "violence": "Violence", + "gore": "Gore", + "spam": "Spam", + "misleading": "Misleading" + } + }, + "card": { + "addedTo": "Added to", + "addedToLower": "added to", + "and": "and", + "communityBookmark": "Community bookmark", + "openInSemble": "Open in Semble", + "deleteConfirm": "Delete this item?", + "hideContent": "Hide Content", + "show": "Show", + "edited": "(edited)", + "annotate": "Annotate", + "untitledBookmark": "Untitled Bookmark", + "addNotePlaceholder": "Add your note to convert this highlight into an annotation…", + "addToCollectionTitle": "Add to Collection", + "annotateTitle": "Annotate this highlight", + "editTitle": "Edit", + "deleteTitle": "Delete", + "report": "Report", + "muteUser": "Mute @{{handle}}", + "blockUser": "Block @{{handle}}", + "convertToAnnotation": "Convert to annotation", + "justNow": "just now", + "labelDescriptions": { + "sexual": "Sexual Content", + "nudity": "Nudity", + "violence": "Violence", + "gore": "Graphic Content", + "spam": "Spam", + "misleading": "Misleading" + } + }, + "profileHoverCard": { + "viewProfile": "View Profile", + "notFound": "Profile not found" + }, + "replyList": { + "noReplies": "No replies yet" + }, + "shareMenu": { + "sembleIntegration": "Semble Integration", + "openOnSemble": "Open on Semble", + "copySembleLink": "Copy Semble Link", + "copyLink": "Copy Link", + "shareViaApp": "Share via App", + "copyUniversalLink": "Copy Universal Link", + "moreOptions": "More Options…", + "copied": "Copied!" + }, + "addToCollection": { + "title": "Add to Collection", + "loading": "Loading collections…", + "collectionNameLabel": "Collection name", + "namePlaceholder": "My Collection", + "descriptionLabel": "Description (optional)", + "descriptionPlaceholder": "What's this collection about?", + "iconLabel": "Icon", + "iconsTab": "Icons", + "emojisTab": "Emojis", + "selected": "Selected:", + "back": "Back", + "create": "Create", + "creating": "Creating…", + "newCollectionButton": "New Collection", + "createNewDescription": "Create a new collection", + "none": "No collections yet", + "done": "Done", + "failedLoad": "Failed to load collections", + "failedAdd": "Failed to add to collection", + "failedCreate": "Failed to create collection" + }, + "editItem": { + "editAnnotation": "Edit Annotation", + "editHighlight": "Edit Highlight", + "editBookmark": "Edit Bookmark", + "textLabel": "Text", + "textPlaceholder": "Write your annotation…", + "colorLabel": "Color", + "tagsLabel": "Tags", + "tagPlaceholder": "Add a tag…", + "contentWarning": "Content Warning", + "cancel": "Cancel", + "save": "Save", + "saving": "Saving…", + "failedSave": "Failed to save changes. Please try again.", + "titleLabel": "Title", + "titlePlaceholder": "Bookmark title", + "descriptionLabel": "Description", + "descriptionPlaceholder": "Optional description…" + }, + "editCollection": { + "title": "Edit Collection", + "nameLabel": "Collection name", + "namePlaceholder": "My Collection", + "descriptionLabel": "Description (optional)", + "descriptionPlaceholder": "What's this collection about?", + "iconLabel": "Icon", + "iconsTab": "Icons", + "emojisTab": "Emojis", + "selected": "Selected:", + "cancel": "Cancel", + "save": "Save Changes", + "saving": "Saving…", + "failedUpdate": "Failed to update collection", + "errorUpdating": "An error occurred while updating" + }, + "externalLink": { + "title": "Leaving Margin", + "message": "You're about to visit an external site.", + "alwaysAllow": "Always allow links to {{hostname}}", + "cancel": "Cancel", + "open": "Open Link" + }, + "report": { + "submitted": "Report submitted", + "submittedMessage": "Thank you. We'll review this shortly.", + "titleUser": "Report @{{handle}}", + "titleGeneric": "Report user", + "reportingContent": "Reporting specific content", + "issueLabel": "What's the issue?", + "cancel": "Cancel", + "submit": "Submit Report", + "submitting": "Submitting…", + "detailsPlaceholder": "Additional details (optional)", + "reasons": { + "spam": "Spam", + "ruleViolation": "Rule violation", + "misleading": "Misleading", + "rudeOrHarassing": "Rude or harassing", + "inappropriateContent": "Inappropriate content", + "other": "Other" + } + }, + "editProfile": { + "title": "Edit Profile", + "avatarLabel": "Avatar", + "uploadButton": "Upload", + "uploading": "Uploading…", + "displayNameLabel": "Display Name", + "bioLabel": "Bio", + "websiteLabel": "Website", + "linksLabel": "Links", + "addLinkPlaceholder": "Add a link…", + "cancel": "Cancel", + "save": "Save", + "saving": "Saving…", + "avatarTypeError": "Please select a JPEG or PNG image", + "avatarSizeError": "Image must be under 2MB", + "avatarUploadError": "Failed to upload: {{message}}" + }, + "iosShortcut": { + "title": "Save from iOS Safari", + "howTo": "How to use the shortcut", + "step1Title": "Install the shortcut", + "step1Link": "Get iOS Shortcut", + "step2Title": "Generate an API Key", + "step2Description": "Create a new key on this settings page and copy it.", + "step3Title": "Configure the shortcut", + "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.", + "step4Title": "To Bookmark a page", + "step4Description": "Don't select any text. Click the menu in Safari, press Share, and select Save to Margin.", + "step5Title": "To Highlight text", + "step5Description": "Select text on the page, click the menu, press Share, and select Save to Margin. Leave the Note field empty.", + "step6Title": "To Add an Annotation", + "step6Description": "Select text, share to Save to Margin (via the menu), enter your custom note in the Note field, and press Done!", + "gotIt": "Got it" + }, + "editHistory": { + "title": "Edit History", + "noHistory": "No edit history found.", + "currentVersion": "Current Version", + "previousVersion": "Previous Version", + "editedAgo": "Edited {{time}} ago", + "postedAgo": "Posted {{time}} ago", + "timeAgo": "{{time}} ago", + "close": "Close", + "failedLoad": "Failed to load edit history" + }, + "settings": { + "title": "Settings", + "sections": { + "profile": "Profile", + "appearance": "Appearance", + "language": "Language", + "batchImport": "Batch Import Highlights", + "apiKeys": "API Keys", + "moderation": "Moderation", + "contentFiltering": "Content Filtering", + "iosShortcut": "iOS Shortcut" }, - "discover": { - "tabs": { - "new": "New", - "popular": "Popular", - "forYou": "For You" - }, - "comingSoon": "Coming soon", - "forYouNotAvailable": "Personalized recommendations aren't available on this server yet.", - "noDocumentsYet": "No documents have been discovered yet. Check back soon!", - "startAnnotating": "Start annotating and highlighting to get personalized recommendations.", - "loadMore": "Load more" + "language": { + "label": "Interface Language", + "description": "Choose the language for the Margin interface." }, - "search": { - "placeholder": "Search annotations, highlights, bookmarks…", - "noResults": "No results found", - "noResultsMessage": "Nothing matched \"{{query}}\". Try different keywords.", - "emptyTitle": "Search your library", - "emptyMessage": "Find annotations, highlights, and bookmarks by keyword, URL, or tag.", - "filters": { - "all": "All", - "annotations": "Annotations", - "highlights": "Highlights", - "bookmarks": "Bookmarks", - "mine": "Mine" - }, - "resultCount": "{{count}}{{hasMore}} results for \"{{query}}\"", - "loadMore": "Load more" + "appearance": { + "disableExternalLinkWarning": "Disable external link warning", + "disableExternalLinkWarningDesc": "Don't ask for confirmation when opening external links", + "communityBookmarks": "Share bookmarks to community feed", + "communityBookmarksDesc": "Your saved bookmarks will appear in the community bookmarks feed" }, - "notifications": { - "title": "Activity", - "noActivity": "No activity yet", - "noActivityMessage": "Interactions with your content will appear here.", - "likedAnnotation": "liked your annotation", - "likedHighlight": "liked your highlight", - "likedBookmark": "liked your bookmark", - "likedReply": "liked your reply", - "likedPost": "liked your post", - "repliedToReply": "replied to your reply", - "repliedToAnnotation": "replied to your annotation", - "mentionedInAnnotation": "mentioned you in an annotation", - "followedYou": "followed you", - "highlightedPage": "highlighted your page", - "inReplyTo": "in reply to", - "aReply": "a reply", - "anAnnotation": "an annotation" + "batchImport": { + "description": "Upload highlights from CSV. Required: url, text. Optional: title, tags, color, created_at" }, - "collections": { - "title": "Collections", - "subtitle": "Organize your annotations and highlights", - "none": "No collections yet", - "noneMessage": "Create a collection to organize your highlights and annotations.", - "createButton": "Create collection", - "newTitle": "New Collection", - "editTitle": "Edit Collection", - "namePlaceholder": "My Collection", - "namePlaceholderEdit": "Collection name", - "nameLabel": "Name", - "iconLabel": "Icon", - "iconsTab": "Icons", - "emojisTab": "Emojis", - "selectedIcon": "Selected:", - "descriptionLabel": "Description (optional)", - "descriptionPlaceholder": "What's this collection for?", - "descriptionPlaceholderEdit": "What's this collection about?", - "cancel": "Cancel", - "create": "Create Collection", - "creating": "Creating…", - "save": "Save Changes", - "saving": "Saving…", - "deleteConfirm": "Delete this collection?", - "failedUpdate": "Failed to update collection", - "errorUpdating": "An error occurred while updating", - "itemCount_one": "{{count}} item", - "itemCount_other": "{{count}} items" + "apiKeys": { + "description": "For the iOS shortcut and other apps", + "keyNamePlaceholder": "Key name, e.g. iOS Shortcut", + "generate": "Generate", + "copyNow": "Copy now - you won't see this again!", + "empty": "No API keys yet. Create one to use with the browser extension.", + "created": "Created {{date}}", + "revokeConfirm": "Revoke this key? Apps using it will stop working." }, - "collectionDetail": { - "backLink": "Collections", - "by": "by", - "edit": "Edit collection", - "delete": "Delete collection", - "removeFromCollection": "Remove from collection", - "viewInSemble": "View in Semble", - "empty": "Collection is empty", - "notFound": "Collection not found", - "failedToLoad": "Failed to load collection", - "deleteConfirm": "Delete this collection?", - "removeConfirm": "Remove from collection?" + "moderation": { + "description": "Manage blocked and muted accounts", + "blockedAccounts": "Blocked accounts ({{count}})", + "noBlocked": "No blocked accounts", + "unblock": "Unblock", + "mutedAccounts": "Muted accounts ({{count}})", + "noMuted": "No muted accounts", + "unmute": "Unmute" }, - "profile": { - "notFound": "User not found", - "notFoundMessage": "This profile doesn't exist or couldn't be loaded.", - "edit": "Edit", - "viewInBluesky": "View profile in Bluesky", - "unblock": "Unblock @{{handle}}", - "block": "Block @{{handle}}", - "unmute": "Unmute @{{handle}}", - "mute": "Mute @{{handle}}", - "report": "Report", - "accountLabeled": "Account labeled: {{description}}", - "labelApplied": "This label was applied by a moderation service you subscribe to.", - "show": "Show", - "hide": "Hide", - "blockedBanner": "You have blocked @{{handle}}", - "blockedMessage": "Their content is hidden from your feeds.", - "mutedBanner": "You have muted @{{handle}}", - "mutedMessage": "Their content is hidden from your feeds.", - "blockedByBanner": "@{{handle}} has blocked you. You cannot interact with their content.", - "unblock_action": "Unblock", - "unmute_action": "Unmute", - "emptyCollectionsOwn": "You haven't created any collections yet.", - "emptyCollectionsOther": "No collections", - "itemCount_one": "{{count}} item", - "itemCount_other": "{{count}} items", - "emptyTabOwn": "Your {{tab}} will show up here.", - "emptyTabOther": "Nothing to see here yet." - }, - "login": { - "signInWith": "Sign in with your", - "handleSuffix": "handle", - "handlePlaceholder": "handle.margin.cafe", - "connecting": "Connecting…", - "continue": "Continue", - "createAccount": "Create New Account", - "termsPrefix": "By signing in, you agree to our", - "termsLink": "Terms of Service", - "termsAnd": "and", - "privacyLink": "Privacy Policy" - }, - "signUp": { - "title": "Create your account", - "subtitle": "Margin adheres to the", - "atProtocol": "AT Protocol", - "subtitleSuffix": ". Choose a provider to host your account.", - "customPdsTitle": "Use a custom PDS", - "customPdsSubtitle": "Enter the address of the PDS hosting your account.", - "pdsAddressLabel": "PDS address", - "pdsAddressPlaceholder": "pds.example.com", - "connecting": "Connecting…", - "back": "Back", - "continue": "Continue", - "invite": "Invite", - "providerError": "Could not connect to this provider. Please try again.", - "customPdsError": "Couldn't connect to that PDS. Double-check the address.", - "providers": { - "margin": { - "name": "Margin", - "description": "The easiest way to get started" - }, - "bluesky": { - "name": "Bluesky", - "description": "The largest and most popular community" - }, - "blacksky": { - "name": "Blacksky", - "description": "For the Culture — a safe space for users and allies" - }, - "eurosky": { - "name": "Eurosky", - "description": "Eurosky is your European home on the Atmosphere" - }, - "selfhostedSocial": { - "name": "selfhosted.social", - "description": "A home for builders, tinkerers, and the curious" - }, - "northsky": { - "name": "Northsky", - "description": "A Canadian worker-owned cooperative" - }, - "tophhie": { - "name": "Tophhie", - "description": "A welcoming and friendly community" - }, - "customPds": { - "name": "Use a custom PDS", - "description": "Already have a PDS? Enter its address." - } - } - }, - "composer": { - "newHighlight": "New highlight", - "newAnnotation": "New annotation", - "newNote": "New note", - "saveHighlight": "Save highlight", - "postAnnotation": "Post annotation", - "postNote": "Post note", - "highlightHint": "Saving a passage without a comment. Add text below to turn it into an annotation.", - "addQuote": "+ Add a quote from the page", - "quotePlaceholder": "Paste or type the text you're annotating…", - "removeQuote": "Remove Quote", - "thoughtsPlaceholder": "Add your thoughts on this passage…", - "mindPlaceholder": "What's on your mind?", - "tagsPlaceholder": "Add tags…", - "contentWarning": "Content Warning", - "contentWarningCount": "Content Warning ({{count}})", - "cancel": "Cancel", - "failedToPost": "Failed to post", - "labels": { - "sexual": "Sexual", - "nudity": "Nudity", - "violence": "Violence", - "gore": "Gore", - "spam": "Spam", - "misleading": "Misleading" - } - }, - "card": { - "addedTo": "Added to", - "addedToLower": "added to", - "and": "and", - "communityBookmark": "Community bookmark", - "openInSemble": "Open in Semble", - "deleteConfirm": "Delete this item?", - "hideContent": "Hide Content", - "show": "Show", - "edited": "(edited)", - "annotate": "Annotate", - "untitledBookmark": "Untitled Bookmark", - "addNotePlaceholder": "Add your note to convert this highlight into an annotation…", - "addToCollectionTitle": "Add to Collection", - "annotateTitle": "Annotate this highlight", - "editTitle": "Edit", - "deleteTitle": "Delete", - "report": "Report", - "muteUser": "Mute @{{handle}}", - "blockUser": "Block @{{handle}}", - "convertToAnnotation": "Convert to annotation", - "justNow": "just now", - "labelDescriptions": { - "sexual": "Sexual Content", - "nudity": "Nudity", - "violence": "Violence", - "gore": "Graphic Content", - "spam": "Spam", - "misleading": "Misleading" - } - }, - "profileHoverCard": { - "viewProfile": "View Profile", - "notFound": "Profile not found" - }, - "replyList": { - "noReplies": "No replies yet" - }, - "shareMenu": { - "sembleIntegration": "Semble Integration", - "openOnSemble": "Open on Semble", - "copySembleLink": "Copy Semble Link", - "copyLink": "Copy Link", - "shareViaApp": "Share via App", - "copyUniversalLink": "Copy Universal Link", - "moreOptions": "More Options…", - "copied": "Copied!" - }, - "addToCollection": { - "title": "Add to Collection", - "loading": "Loading collections…", - "collectionNameLabel": "Collection name", - "namePlaceholder": "My Collection", - "descriptionLabel": "Description (optional)", - "descriptionPlaceholder": "What's this collection about?", - "iconLabel": "Icon", - "iconsTab": "Icons", - "emojisTab": "Emojis", - "selected": "Selected:", - "back": "Back", - "create": "Create", - "creating": "Creating…", - "newCollectionButton": "New Collection", - "createNewDescription": "Create a new collection", - "none": "No collections yet", - "done": "Done", - "failedLoad": "Failed to load collections", - "failedAdd": "Failed to add to collection", - "failedCreate": "Failed to create collection" - }, - "editItem": { - "editAnnotation": "Edit Annotation", - "editHighlight": "Edit Highlight", - "editBookmark": "Edit Bookmark", - "textLabel": "Text", - "textPlaceholder": "Write your annotation…", - "colorLabel": "Color", - "tagsLabel": "Tags", - "tagPlaceholder": "Add a tag…", - "contentWarning": "Content Warning", - "cancel": "Cancel", - "save": "Save", - "saving": "Saving…", - "failedSave": "Failed to save changes. Please try again.", - "titleLabel": "Title", - "titlePlaceholder": "Bookmark title", - "descriptionLabel": "Description", - "descriptionPlaceholder": "Optional description…" - }, - "editCollection": { - "title": "Edit Collection", - "nameLabel": "Collection name", - "namePlaceholder": "My Collection", - "descriptionLabel": "Description (optional)", - "descriptionPlaceholder": "What's this collection about?", - "iconLabel": "Icon", - "iconsTab": "Icons", - "emojisTab": "Emojis", - "selected": "Selected:", - "cancel": "Cancel", - "save": "Save Changes", - "saving": "Saving…", - "failedUpdate": "Failed to update collection", - "errorUpdating": "An error occurred while updating" - }, - "externalLink": { - "title": "Leaving Margin", - "message": "You're about to visit an external site.", - "alwaysAllow": "Always allow links to {{hostname}}", - "cancel": "Cancel", - "open": "Open Link" - }, - "report": { - "submitted": "Report submitted", - "submittedMessage": "Thank you. We'll review this shortly.", - "titleUser": "Report @{{handle}}", - "titleGeneric": "Report user", - "reportingContent": "Reporting specific content", - "issueLabel": "What's the issue?", - "cancel": "Cancel", - "submit": "Submit Report", - "submitting": "Submitting…", - "detailsPlaceholder": "Additional details (optional)", - "reasons": { - "spam": "Spam", - "ruleViolation": "Rule violation", - "misleading": "Misleading", - "rudeOrHarassing": "Rude or harassing", - "inappropriateContent": "Inappropriate content", - "other": "Other" - } - }, - "editProfile": { - "title": "Edit Profile", - "avatarLabel": "Avatar", - "uploadButton": "Upload", - "uploading": "Uploading…", - "displayNameLabel": "Display Name", - "bioLabel": "Bio", - "websiteLabel": "Website", - "linksLabel": "Links", - "addLinkPlaceholder": "Add a link…", - "cancel": "Cancel", - "save": "Save", - "saving": "Saving…", - "avatarTypeError": "Please select a JPEG or PNG image", - "avatarSizeError": "Image must be under 2MB", - "avatarUploadError": "Failed to upload: {{message}}" + "contentFiltering": { + "description": "Subscribe to labelers and configure how labeled content appears", + "subscribedLabelers": "Subscribed Labelers", + "noLabelers": "No labelers subscribed", + "labelerDidPlaceholder": "did:plc:… (labeler DID)", + "remove": "Remove", + "add": "Add", + "labelVisibility": "Label Visibility", + "labelVisibilityDesc": "Choose how to handle each label type: Warn shows a blur overlay, Hide removes content entirely, Ignore shows content normally.", + "warn": "Warn", + "hide": "Hide", + "ignore": "Ignore" }, "iosShortcut": { - "title": "Save from iOS Safari", - "howTo": "How to use the shortcut", - "step1Title": "Install the shortcut", - "step1Link": "Get iOS Shortcut", - "step2Title": "Generate an API Key", - "step2Description": "Create a new key on this settings page and copy it.", - "step3Title": "Configure the shortcut", - "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.", - "step4Title": "To Bookmark a page", - "step4Description": "Don't select any text. Click the menu in Safari, press Share, and select Save to Margin.", - "step5Title": "To Highlight text", - "step5Description": "Select text on the page, click the menu, press Share, and select Save to Margin. Leave the Note field empty.", - "step6Title": "To Add an Annotation", - "step6Description": "Select text, share to Save to Margin (via the menu), enter your custom note in the Note field, and press Done!", - "gotIt": "Got it" + "description": "Save pages to Margin from Safari on iPhone and iPad", + "setupButton": "Setup iOS Shortcut" }, - "editHistory": { - "title": "Edit History", - "noHistory": "No edit history found.", - "currentVersion": "Current Version", - "previousVersion": "Previous Version", - "editedAgo": "Edited {{time}} ago", - "postedAgo": "Posted {{time}} ago", - "timeAgo": "{{time}} ago", - "close": "Close", - "failedLoad": "Failed to load edit history" + "logout": "Log out" + }, + "new": { + "signInRequired": "Sign in to create", + "needsAccount": "You need a Bluesky account", + "signInButton": "Sign in with Bluesky", + "composeTitle": "Compose", + "composeTagline": "Highlight a passage, leave a note, or annotate a page — all from here.", + "urlLabel": "URL to annotate", + "urlPlaceholder": "https://example.com/article" + }, + "annotationDetail": { + "back": "Back", + "replies": "Replies ({{count}})", + "replyingTo": "Replying to", + "replyPlaceholder": "Write a reply…", + "reply": "Reply", + "signInToReply": "Sign in to reply", + "logIn": "Log in", + "notFound": "Not found", + "mayBeDeleted": "This may have been deleted.", + "backToFeed": "Back to Feed", + "deleteReplyConfirm": "Delete this reply?", + "failedReply": "Failed to post reply: {{message}}", + "failedDelete": "Failed to delete: {{message}}", + "failedResolve": "Failed to resolve handle: {{message}}" + }, + "urlPage": { + "title": "URL Annotations", + "description": "Enter a URL to see all public annotations and highlights from the Margin community.", + "urlPlaceholder": "https://example.com/article", + "view": "View", + "myAnnotations": "My Annotations", + "share": "Share", + "copied": "Copied!", + "contributor_one": "{{count}} contributor", + "contributor_other": "{{count}} contributors", + "loadingAnnotations": "Loading annotations…", + "blankCanvas": "This page is a blank canvas", + "blankCanvasMessage": "No one's left notes here yet. Want to be the first? Grab the Margin extension and share what you're thinking.", + "tabs": { + "all": "All", + "annotations": "Annotations", + "highlights": "Highlights", + "bookmarks": "Bookmarks", + "collections": "Collections" }, - "settings": { - "title": "Settings", - "sections": { - "profile": "Profile", - "appearance": "Appearance", - "language": "Language", - "batchImport": "Batch Import Highlights", - "apiKeys": "API Keys", - "moderation": "Moderation", - "contentFiltering": "Content Filtering", - "iosShortcut": "iOS Shortcut" - }, - "language": { - "label": "Interface Language", - "description": "Choose the language for the Margin interface." - }, - "appearance": { - "disableExternalLinkWarning": "Disable external link warning", - "disableExternalLinkWarningDesc": "Don't ask for confirmation when opening external links", - "communityBookmarks": "Share bookmarks to community feed", - "communityBookmarksDesc": "Your saved bookmarks will appear in the community bookmarks feed" - }, - "batchImport": { - "description": "Upload highlights from CSV. Required: url, text. Optional: title, tags, color, created_at" - }, - "apiKeys": { - "description": "For the iOS shortcut and other apps", - "keyNamePlaceholder": "Key name, e.g. iOS Shortcut", - "generate": "Generate", - "copyNow": "Copy now - you won't see this again!", - "empty": "No API keys yet. Create one to use with the browser extension.", - "created": "Created {{date}}", - "revokeConfirm": "Revoke this key? Apps using it will stop working." - }, - "moderation": { - "description": "Manage blocked and muted accounts", - "blockedAccounts": "Blocked accounts ({{count}})", - "noBlocked": "No blocked accounts", - "unblock": "Unblock", - "mutedAccounts": "Muted accounts ({{count}})", - "noMuted": "No muted accounts", - "unmute": "Unmute" - }, - "contentFiltering": { - "description": "Subscribe to labelers and configure how labeled content appears", - "subscribedLabelers": "Subscribed Labelers", - "noLabelers": "No labelers subscribed", - "labelerDidPlaceholder": "did:plc:… (labeler DID)", - "remove": "Remove", - "add": "Add", - "labelVisibility": "Label Visibility", - "labelVisibilityDesc": "Choose how to handle each label type: Warn shows a blur overlay, Hide removes content entirely, Ignore shows content normally.", - "warn": "Warn", - "hide": "Hide", - "ignore": "Ignore" - }, - "iosShortcut": { - "description": "Save pages to Margin from Safari on iPhone and iPad", - "setupButton": "Setup iOS Shortcut" - }, - "logout": "Log out" + "noAnnotationsYet": "No annotations yet", + "noAnnotationsMessage": "Nobody has left a written note on this page.", + "noHighlightsYet": "No highlights yet", + "noHighlightsMessage": "Nobody has highlighted a passage from this page.", + "loadMore": "Load more", + "loading": "Loading…", + "failedLoadMore": "Failed to load more: {{message}}" + }, + "userUrlPage": { + "on": "on", + "loadingAnnotations": "Loading annotations…", + "noUrl": "No URL specified", + "noUrlMessage": "Please provide a URL to view annotations.", + "noItems": "No items found", + "noItemsMessage": "{{name}} hasn't annotated this page yet.", + "noAnnotations": "No annotations", + "noHighlights": "No highlights", + "loadMore": "Load more", + "loading": "Loading…", + "failedLoadMore": "Failed to load more: {{message}}" + }, + "adminModeration": { + "accessDenied": "Access Denied", + "accessDeniedMessage": "You don't have permission to access the moderation dashboard.", + "title": "Moderation", + "stats": "{{pending}} pending · {{total}} total reports", + "tabs": { + "reports": "Reports", + "actions": "Actions", + "labels": "Labels" }, - "new": { - "signInRequired": "Sign in to create", - "needsAccount": "You need a Bluesky account", - "signInButton": "Sign in with Bluesky", - "composeTitle": "Compose", - "composeTagline": "Highlight a passage, leave a note, or annotate a page — all from here.", - "urlLabel": "URL to annotate", - "urlPlaceholder": "https://example.com/article" + "filters": { + "all": "All", + "pending": "Pending", + "resolved": "Resolved", + "dismissed": "Dismissed", + "escalated": "Escalated" }, - "annotationDetail": { - "back": "Back", - "replies": "Replies ({{count}})", - "replyingTo": "Replying to", - "replyPlaceholder": "Write a reply…", - "reply": "Reply", - "signInToReply": "Sign in to reply", - "logIn": "Log in", - "notFound": "Not found", - "mayBeDeleted": "This may have been deleted.", - "backToFeed": "Back to Feed", - "deleteReplyConfirm": "Delete this reply?", - "failedReply": "Failed to post reply: {{message}}", - "failedDelete": "Failed to delete: {{message}}", - "failedResolve": "Failed to resolve handle: {{message}}" + "reports": { + "empty": "No reports", + "emptyPending": "No pending reports to review.", + "emptyFiltered": "No {{status}} reports found.", + "reportedUser": "Reported User", + "reporter": "Reporter", + "details": "Details", + "contentUri": "Content URI", + "acknowledge": "Acknowledge", + "dismiss": "Dismiss", + "takedown": "Takedown" }, - "urlPage": { - "title": "URL Annotations", - "description": "Enter a URL to see all public annotations and highlights from the Margin community.", - "urlPlaceholder": "https://example.com/article", - "view": "View", - "myAnnotations": "My Annotations", - "share": "Share", - "copied": "Copied!", - "contributor_one": "{{count}} contributor", - "contributor_other": "{{count}} contributors", - "loadingAnnotations": "Loading annotations…", - "blankCanvas": "This page is a blank canvas", - "blankCanvasMessage": "No one's left notes here yet. Want to be the first? Grab the Margin extension and share what you're thinking.", - "tabs": { - "all": "All", - "annotations": "Annotations", - "highlights": "Highlights", - "bookmarks": "Bookmarks", - "collections": "Collections" - }, - "noAnnotationsYet": "No annotations yet", - "noAnnotationsMessage": "Nobody has left a written note on this page.", - "noHighlightsYet": "No highlights yet", - "noHighlightsMessage": "Nobody has highlighted a passage from this page.", - "loadMore": "Load more", - "loading": "Loading…", - "failedLoadMore": "Failed to load more: {{message}}" + "reasons": { + "spam": "Spam", + "violation": "Rule Violation", + "misleading": "Misleading", + "sexual": "Inappropriate", + "rude": "Rude / Harassing", + "other": "Other" }, - "userUrlPage": { - "on": "on", - "loadingAnnotations": "Loading annotations…", - "noUrl": "No URL specified", - "noUrlMessage": "Please provide a URL to view annotations.", - "noItems": "No items found", - "noItemsMessage": "{{name}} hasn't annotated this page yet.", - "noAnnotations": "No annotations", - "noHighlights": "No highlights", - "loadMore": "Load more", - "loading": "Loading…", - "failedLoadMore": "Failed to load more: {{message}}" + "actions": { + "applyWarning": "Apply Content Warning", + "applyWarningDesc": "Add a content warning label to a specific post or account. Users will see a blur overlay with the option to reveal.", + "accountDid": "Account DID", + "contentUri": "Content URI", + "contentUriOptional": "optional — leave empty for account-level label", + "labelType": "Label Type", + "applyLabel": "Apply Label", + "labelApplied": "Label applied" }, - "adminModeration": { - "accessDenied": "Access Denied", - "accessDeniedMessage": "You don't have permission to access the moderation dashboard.", - "title": "Moderation", - "stats": "{{pending}} pending · {{total}} total reports", - "tabs": { - "reports": "Reports", - "actions": "Actions", - "labels": "Labels" - }, - "filters": { - "all": "All", - "pending": "Pending", - "resolved": "Resolved", - "dismissed": "Dismissed", - "escalated": "Escalated" - }, - "reports": { - "empty": "No reports", - "emptyPending": "No pending reports to review.", - "emptyFiltered": "No {{status}} reports found.", - "reportedUser": "Reported User", - "reporter": "Reporter", - "details": "Details", - "contentUri": "Content URI", - "acknowledge": "Acknowledge", - "dismiss": "Dismiss", - "takedown": "Takedown" - }, - "reasons": { - "spam": "Spam", - "violation": "Rule Violation", - "misleading": "Misleading", - "sexual": "Inappropriate", - "rude": "Rude / Harassing", - "other": "Other" - }, - "actions": { - "applyWarning": "Apply Content Warning", - "applyWarningDesc": "Add a content warning label to a specific post or account. Users will see a blur overlay with the option to reveal.", - "accountDid": "Account DID", - "contentUri": "Content URI", - "contentUriOptional": "optional — leave empty for account-level label", - "labelType": "Label Type", - "applyLabel": "Apply Label", - "labelApplied": "Label applied" - }, - "labels": { - "empty": "No labels", - "emptyMessage": "No content labels have been applied yet.", - "accountLevel": "Account-level label", - "removeConfirm": "Remove this label?", - "removeTitle": "Remove label" - } - }, - "highlightImporter": { - "clickToUpload": "Click to upload CSV", - "processing": "Processing…", - "requiredColumns": "Required columns: url, text | Optional: title, tags, color, created_at", - "downloadTemplate": "Download Template", - "importProgress": "Import Progress", - "complete": "{{rate}}% complete", - "failed_one": "{{count}} failed", - "failed_other": "{{count}} failed", - "importing": "Importing highlights…", - "success": "Successfully imported {{count}} highlights!", - "errorsTitle": "{{count}} errors during import", - "row": "Row {{row}}: {{error}}", - "moreErrors": "+{{count}} more errors", - "importAnother": "Import Another File", - "noHighlights": "No valid highlights found in CSV", - "csvMustHaveUrl": "CSV must have a 'url' column", - "csvMustHaveText": "CSV must have a 'text' column (also matches: highlight, excerpt)", - "errorParsing": "Error parsing CSV: {{message}}" - }, - "common": { - "loading": "Loading…", - "cancel": "Cancel", - "save": "Save", - "close": "Close", - "back": "Back", - "continue": "Continue", - "error": "Error", - "retry": "Retry", - "loadMore": "Load more", - "new": "New" - }, - "about": { - "nav": { - "getExtension": "Get Extension", - "install": "Install" - }, - "hero": { - "openSource": "Fully open source", - "headline": "Write on the margins", - "headlineAccent": "of the internet.", - "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", - "atProtocol": "AT Protocol", - "descriptionPost": ". Not locked in a silo.", - "openApp": "Open App", - "getStarted": "Get Started", - "installFor": "Install for {{browser}}" - }, - "features": { - "title": "Everything you need to engage with the web", - "subtitle": "More than bookmarks. A full toolkit for reading, thinking, and sharing on the open web.", - "annotations": { - "title": "Annotations", - "description": "Leave notes on any web page. Start discussions, share insights, or just jot down your thoughts for later." - }, - "highlights": { - "title": "Highlights", - "description": "Select and highlight text on any page with customizable colors. Your highlights are rendered inline with the CSS Highlights API." - }, - "bookmarks": { - "title": "Bookmarks", - "description": "Save pages with one click or a keyboard shortcut. All your bookmarks are synced to your AT Protocol identity." - }, - "collections": { - "title": "Collections", - "description": "Organize your annotations, highlights, and bookmarks into themed collections. Share them publicly or keep them private." - }, - "socialDiscovery": { - "title": "Social Discovery", - "description": "See what others are saying about the pages you visit. Discover annotations, trending tags, and connect with other readers." - }, - "tagsSearch": { - "title": "Tags & Search", - "description": "Tag your annotations for easy retrieval. Search by URL, tag, or content to find exactly what you're looking for." - } - }, - "extension": { - "badge": "Browser Extension", - "title": "Your annotation toolkit,", - "titleLine2": "right in the browser", - "description": "The Margin extension brings the full annotation experience directly into every page you visit. Just select, annotate, and go.", - "iosShortcut": "iOS Shortcut", - "features": { - "inlineOverlay": { - "title": "Inline Overlay", - "description": "See annotations and highlights rendered directly on the page. Uses the CSS Highlights API for beautiful, native-feeling text underlines." - }, - "contextMenu": { - "title": "Context Menu & Selection", - "description": "Right-click any selected text to annotate, highlight, or quote it. Or just right-click the page to bookmark it instantly." - }, - "keyboard": { - "title": "Keyboard Shortcuts", - "description": "Toggle the overlay, bookmark the current page, or annotate selected text without reaching for the mouse." - }, - "sidePanel": { - "title": "Side Panel", - "description": "Open the Margin side panel to browse annotations, bookmarks, and collections without leaving the page you're reading." - } - } - }, - "protocol": { - "badge": "Decentralized", - "title": "Your data, your identity", - "descriptionPre": "Margin is built on the", - "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.", - "point0": "Sign in with your AT Protocol handle, no new account needed", - "point1": "Your data lives in your PDS, portable and under your control", - "point2": "Custom Lexicon schemas for annotations, highlights, collections & more", - "point3": "Fully open source, check out the code and contribute" - }, - "cta": { - "title": "Start writing on the margins", - "description": "Join the open annotation layer. Sign in with your AT Protocol identity and install the extension to get started.", - "signIn": "Sign in", - "viewGitHub": "View on GitHub", - "viewTangled": "View on Tangled" - }, - "footer": { - "privacy": "Privacy", - "terms": "Terms", - "contact": "Contact" - } + "labels": { + "empty": "No labels", + "emptyMessage": "No content labels have been applied yet.", + "accountLevel": "Account-level label", + "removeConfirm": "Remove this label?", + "removeTitle": "Remove label" } + }, + "highlightImporter": { + "clickToUpload": "Click to upload CSV", + "processing": "Processing…", + "requiredColumns": "Required columns: url, text | Optional: title, tags, color, created_at", + "downloadTemplate": "Download Template", + "importProgress": "Import Progress", + "complete": "{{rate}}% complete", + "failed_one": "{{count}} failed", + "failed_other": "{{count}} failed", + "importing": "Importing highlights…", + "success": "Successfully imported {{count}} highlights!", + "errorsTitle": "{{count}} errors during import", + "row": "Row {{row}}: {{error}}", + "moreErrors": "+{{count}} more errors", + "importAnother": "Import Another File", + "noHighlights": "No valid highlights found in CSV", + "csvMustHaveUrl": "CSV must have a 'url' column", + "csvMustHaveText": "CSV must have a 'text' column (also matches: highlight, excerpt)", + "errorParsing": "Error parsing CSV: {{message}}" + }, + "common": { + "loading": "Loading…", + "cancel": "Cancel", + "save": "Save", + "close": "Close", + "back": "Back", + "continue": "Continue", + "error": "Error", + "retry": "Retry", + "loadMore": "Load more", + "new": "New" + }, + "about": { + "nav": { + "getExtension": "Get Extension", + "install": "Install" + }, + "hero": { + "openSource": "Fully open source", + "headline": "Write on the margins", + "headlineAccent": "of the internet.", + "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", + "atProtocol": "AT Protocol", + "descriptionPost": ". Not locked in a silo.", + "openApp": "Open App", + "getStarted": "Get Started", + "installFor": "Install for {{browser}}" + }, + "features": { + "title": "Everything you need to engage with the web", + "subtitle": "More than bookmarks. A full toolkit for reading, thinking, and sharing on the open web.", + "annotations": { + "title": "Annotations", + "description": "Leave notes on any web page. Start discussions, share insights, or just jot down your thoughts for later." + }, + "highlights": { + "title": "Highlights", + "description": "Select and highlight text on any page with customizable colors. Your highlights are rendered inline with the CSS Highlights API." + }, + "bookmarks": { + "title": "Bookmarks", + "description": "Save pages with one click or a keyboard shortcut. All your bookmarks are synced to your AT Protocol identity." + }, + "collections": { + "title": "Collections", + "description": "Organize your annotations, highlights, and bookmarks into themed collections. Share them publicly or keep them private." + }, + "socialDiscovery": { + "title": "Social Discovery", + "description": "See what others are saying about the pages you visit. Discover annotations, trending tags, and connect with other readers." + }, + "tagsSearch": { + "title": "Tags & Search", + "description": "Tag your annotations for easy retrieval. Search by URL, tag, or content to find exactly what you're looking for." + } + }, + "extension": { + "badge": "Browser Extension", + "title": "Your annotation toolkit,", + "titleLine2": "right in the browser", + "description": "The Margin extension brings the full annotation experience directly into every page you visit. Just select, annotate, and go.", + "iosShortcut": "iOS Shortcut", + "features": { + "inlineOverlay": { + "title": "Inline Overlay", + "description": "See annotations and highlights rendered directly on the page. Uses the CSS Highlights API for beautiful, native-feeling text underlines." + }, + "contextMenu": { + "title": "Context Menu & Selection", + "description": "Right-click any selected text to annotate, highlight, or quote it. Or just right-click the page to bookmark it instantly." + }, + "keyboard": { + "title": "Keyboard Shortcuts", + "description": "Toggle the overlay, bookmark the current page, or annotate selected text without reaching for the mouse." + }, + "sidePanel": { + "title": "Side Panel", + "description": "Open the Margin side panel to browse annotations, bookmarks, and collections without leaving the page you're reading." + } + } + }, + "protocol": { + "badge": "Decentralized", + "title": "Your data, your identity", + "descriptionPre": "Margin is built on the", + "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.", + "point0": "Sign in with your AT Protocol handle, no new account needed", + "point1": "Your data lives in your PDS, portable and under your control", + "point2": "Custom Lexicon schemas for annotations, highlights, collections & more", + "point3": "Fully open source, check out the code and contribute" + }, + "cta": { + "title": "Start writing on the margins", + "description": "Join the open annotation layer. Sign in with your AT Protocol identity and install the extension to get started.", + "signIn": "Sign in", + "viewGitHub": "View on GitHub", + "viewTangled": "View on Tangled" + }, + "footer": { + "privacy": "Privacy", + "terms": "Terms", + "contact": "Contact" + } + } } diff --git a/web/public/locales/es/translation.json b/web/public/locales/es/translation.json --- a/web/public/locales/es/translation.json +++ b/web/public/locales/es/translation.json @@ -1,378 +1,378 @@ { - "appTitle": "Margin", - "nav": { - "settings": "Ajustes", - "annotations": "Anotaciones", - "collections": "Colecciones", - "activity": "Actividad", - "signIn": "Iniciar sesión", - "logOut": "Cerrar sesión", - "themeLight": "Claro", - "themeDark": "Oscuro", - "themeSystem": "Sistema", - "bookmarks": "Marcadores", - "new": "Nuevo", - "feed": "Feed", - "highlights": "Resaltados" + "appTitle": "Margin", + "nav": { + "settings": "Ajustes", + "annotations": "Anotaciones", + "collections": "Colecciones", + "activity": "Actividad", + "signIn": "Iniciar sesión", + "logOut": "Cerrar sesión", + "themeLight": "Claro", + "themeDark": "Oscuro", + "themeSystem": "Sistema", + "bookmarks": "Marcadores", + "new": "Nuevo", + "feed": "Feed", + "highlights": "Resaltados" + }, + "pageTitles": { + "home": "Inicio — Margin", + "bookmarks": "Marcadores — Margin", + "annotations": "Anotaciones — Margin", + "search": "Buscar — Margin", + "notifications": "Notificaciones — Margin", + "new": "Nueva anotación — Margin", + "settings": "Configuración — Margin", + "collections": "Colecciones — Margin", + "admin": "Admin — Margin", + "highlights": "Resaltados — Margin" + }, + "sidebar": { + "getExtension": "Obtén la extensión", + "extensionTagline": "Resalta, anota y guarda marcadores desde cualquier página.", + "downloadForFirefox": "Descargar para Firefox", + "downloadForEdge": "Descargar para Edge", + "downloadForChrome": "Descargar para Chrome", + "trending": "Tendencias", + "nothingTrending": "No hay nada en tendencia ahora mismo.", + "searchPlaceholder": "Buscar personas, etiquetas, URLs…", + "copyright": "© 2026 Padding Labs LLC" + }, + "mobileNav": { + "iosShortcut": "Atajo de iOS" + }, + "feed": { + "welcome": "Bienvenido a Margin", + "welcomeTagline": "Un lugar tranquilo para anotar, resaltar y guardar lo que lees en la web.", + "getStarted": "Empezar", + "learnMore": "Más información", + "tabs": { + "recent": "Recientes", + "popular": "Popular", + "margin": "Margin", + "semble": "Semble" }, - "pageTitles": { - "home": "Inicio — Margin", - "bookmarks": "Marcadores — Margin", - "annotations": "Anotaciones — Margin", - "search": "Buscar — Margin", - "notifications": "Notificaciones — Margin", - "new": "Nueva anotación — Margin", - "settings": "Configuración — Margin", - "collections": "Colecciones — Margin", - "admin": "Admin — Margin", - "highlights": "Resaltados — Margin" + "filters": { + "all": "Todo", + "annotations": "Anotaciones", + "highlights": "Resaltados", + "bookmarks": "Marcadores" }, - "sidebar": { - "getExtension": "Obtén la extensión", - "extensionTagline": "Resalta, anota y guarda marcadores desde cualquier página.", - "downloadForFirefox": "Descargar para Firefox", - "downloadForEdge": "Descargar para Edge", - "downloadForChrome": "Descargar para Chrome", - "trending": "Tendencias", - "nothingTrending": "No hay nada en tendencia ahora mismo.", - "searchPlaceholder": "Buscar personas, etiquetas, URLs…", - "copyright": "© 2026 Padding Labs LLC" + "itemsWithTag": "Elementos con la etiqueta:", + "clearFilter": "Limpiar filtro", + "everyone": "Todos", + "mine": "Mío", + "defaultEmptyMessage": "Todavía no hay nada aquí — las anotaciones tuyas y de las personas a las que sigues aparecerán aquí.", + "nothingHereYet": "Aún no hay nada aquí", + "loading": "Cargando…" + }, + "discover": { + "tabs": { + "new": "Nuevo", + "popular": "Popular", + "forYou": "Para ti" }, - "mobileNav": { - "iosShortcut": "Atajo de iOS" + "comingSoon": "Próximamente", + "forYouNotAvailable": "Las recomendaciones personalizadas aún no están disponibles en este servidor.", + "noDocumentsYet": "Aún no se han descubierto documentos. ¡Vuelve pronto!", + "startAnnotating": "Empieza a anotar y resaltar para recibir recomendaciones personalizadas.", + "loadMore": "Cargar más" + }, + "search": { + "placeholder": "Buscar anotaciones, resaltados, marcadores…", + "noResults": "No se encontraron resultados", + "noResultsMessage": "No se encontró nada que coincida con \"{{query}}\". Prueba con otras palabras clave.", + "emptyTitle": "Busca en tu biblioteca", + "emptyMessage": "Busca anotaciones, resaltados y marcadores por palabra clave, URL o etiqueta.", + "filters": { + "all": "Todo", + "annotations": "Anotaciones", + "bookmarks": "Marcadores", + "mine": "Mío", + "highlights": "Resaltados" }, - "feed": { - "welcome": "Bienvenido a Margin", - "welcomeTagline": "Un lugar tranquilo para anotar, resaltar y guardar lo que lees en la web.", - "getStarted": "Empezar", - "learnMore": "Más información", - "tabs": { - "recent": "Recientes", - "popular": "Popular", - "margin": "Margin", - "semble": "Semble" - }, - "filters": { - "all": "Todo", - "annotations": "Anotaciones", - "highlights": "Resaltados", - "bookmarks": "Marcadores" - }, - "itemsWithTag": "Elementos con la etiqueta:", - "clearFilter": "Limpiar filtro", - "everyone": "Todos", - "mine": "Mío", - "defaultEmptyMessage": "Todavía no hay nada aquí — las anotaciones tuyas y de las personas a las que sigues aparecerán aquí.", - "nothingHereYet": "Aún no hay nada aquí", - "loading": "Cargando…" - }, - "discover": { - "tabs": { - "new": "Nuevo", - "popular": "Popular", - "forYou": "Para ti" - }, - "comingSoon": "Próximamente", - "forYouNotAvailable": "Las recomendaciones personalizadas aún no están disponibles en este servidor.", - "noDocumentsYet": "Aún no se han descubierto documentos. ¡Vuelve pronto!", - "startAnnotating": "Empieza a anotar y resaltar para recibir recomendaciones personalizadas.", - "loadMore": "Cargar más" - }, - "search": { - "placeholder": "Buscar anotaciones, resaltados, marcadores…", - "noResults": "No se encontraron resultados", - "noResultsMessage": "No se encontró nada que coincida con \"{{query}}\". Prueba con otras palabras clave.", - "emptyTitle": "Busca en tu biblioteca", - "emptyMessage": "Busca anotaciones, resaltados y marcadores por palabra clave, URL o etiqueta.", - "filters": { - "all": "Todo", - "annotations": "Anotaciones", - "bookmarks": "Marcadores", - "mine": "Mío", - "highlights": "Resaltados" - }, - "resultCount": "{{count}}{{hasMore}} resultados para \"{{query}}\"", - "loadMore": "Cargar más" - }, - "notifications": { - "title": "Actividad", - "noActivity": "Aún no hay actividad", - "noActivityMessage": "Las interacciones con tu contenido aparecerán aquí.", - "likedAnnotation": "le gustó tu anotación", - "likedHighlight": "le dio me gusta a tu resaltado", - "likedBookmark": "le dio me gusta a tu marcador", - "likedReply": "le gustó tu respuesta", - "likedPost": "le dio me gusta a tu publicación", - "repliedToReply": "respondió a tu respuesta", - "repliedToAnnotation": "respondió a tu anotación", - "mentionedInAnnotation": "te mencionó en una anotación", - "followedYou": "te empezó a seguir", - "highlightedPage": "resaltó tu página", - "inReplyTo": "en respuesta a", - "aReply": "una respuesta", - "anAnnotation": "una anotación" - }, - "collections": { - "title": "Colecciones", - "subtitle": "Organiza tus anotaciones y resaltados", - "none": "Aún no hay colecciones", - "noneMessage": "Crea una colección para organizar tus resaltados y anotaciones.", - "createButton": "Crear colección", - "newTitle": "Nueva colección", - "editTitle": "Editar colección", - "namePlaceholder": "Mi colección", - "namePlaceholderEdit": "Nombre de la colección", - "nameLabel": "Nombre", - "iconLabel": "Icono", - "iconsTab": "Iconos", - "emojisTab": "Emojis", - "selectedIcon": "Seleccionado:", - "descriptionLabel": "Descripción (opcional)", - "descriptionPlaceholder": "¿Para qué es esta colección?", - "descriptionPlaceholderEdit": "¿De qué trata esta colección?", - "cancel": "Cancelar", - "create": "Crear colección", - "creating": "Creando…", - "save": "Guardar cambios", - "saving": "Guardando…", - "deleteConfirm": "¿Eliminar esta colección?", - "failedUpdate": "Error al actualizar la colección", - "errorUpdating": "Ocurrió un error al actualizar", - "itemCount_one": "{{count}} elemento", - "itemCount_many": "", - "itemCount_other": "{{count}} elementos" - }, - "collectionDetail": { - "by": "por", - "edit": "Editar colección", - "delete": "Eliminar colección", - "removeFromCollection": "Quitar de la colección", - "viewInSemble": "Ver en Semble", - "empty": "La colección está vacía", - "notFound": "Colección no encontrada", - "failedToLoad": "Error al cargar la colección", - "deleteConfirm": "¿Eliminar esta colección?", - "removeConfirm": "¿Eliminar de la colección?", - "backLink": "Colecciones" - }, - "profile": { - "notFound": "Usuario no encontrado", - "notFoundMessage": "Este perfil no existe o no se pudo cargar.", - "edit": "Editar", - "viewInBluesky": "Ver perfil en Bluesky", - "unblock": "Desbloquear a {{handle}}", - "block": "Bloquear a {{handle}}", - "unmute": "Desactivar silencio de {{handle}}", - "mute": "Silenciar a {{handle}}", - "report": "Reportar", - "accountLabeled": "Cuenta etiquetada: {{description}}", - "labelApplied": "Esta etiqueta fue aplicada por un servicio de moderación al que estás suscrito.", - "show": "Mostrar", - "hide": "Ocultar", - "blockedBanner": "Has bloqueado a {{handle}}", - "blockedMessage": "Su contenido está oculto de tus feeds.", - "mutedBanner": "Has silenciado a {{handle}}", - "mutedMessage": "Su contenido está oculto de tus feeds.", - "blockedByBanner": "{{handle}} te ha bloqueado. No puedes interactuar con su contenido.", - "unblock_action": "Desbloquear", - "unmute_action": "Activar sonido", - "emptyCollectionsOwn": "Aún no has creado ninguna colección.", - "emptyCollectionsOther": "Sin colecciones", - "itemCount_one": "{{count}} elemento", - "itemCount_many": "", - "itemCount_other": "{{count}} elementos", - "emptyTabOwn": "Tus {{tab}} aparecerán aquí.", - "emptyTabOther": "Aún no hay nada que ver aquí." - }, - "login": { - "signInWith": "Inicia sesión con tu", - "handleSuffix": "handle", - "handlePlaceholder": "handle.margin.cafe", - "connecting": "Conectando…", - "continue": "Continuar", - "createAccount": "Crear cuenta nueva", - "termsPrefix": "Al iniciar sesión, aceptas nuestros", - "termsLink": "Términos de servicio", - "termsAnd": "y", - "privacyLink": "Política de privacidad" - }, - "signUp": { - "title": "Crea tu cuenta", - "subtitle": "Margin se adhiere al", - "atProtocol": "AT Protocol", - "subtitleSuffix": ". Elige un proveedor para alojar tu cuenta.", - "customPdsTitle": "Usa un PDS personalizado", - "customPdsSubtitle": "Introduce la dirección del PDS que aloja tu cuenta.", - "pdsAddressLabel": "Dirección del PDS", - "pdsAddressPlaceholder": "pds.example.com", - "connecting": "Conectando…", - "back": "Volver", - "continue": "Continuar", - "invite": "Invitar", - "providerError": "No se pudo conectar con este proveedor. Por favor, inténtalo de nuevo.", - "customPdsError": "No se pudo conectar con ese PDS. Revisa la dirección.", - "providers": { - "margin": { - "description": "La forma más fácil de empezar", - "name": "Margin" - }, - "bluesky": { - "name": "Bluesky", - "description": "La comunidad más grande y popular" - }, - "blacksky": { - "name": "Blacksky", - "description": "Para la cultura — un espacio seguro para usuarios y aliados" - }, - "eurosky": { - "name": "Eurosky", - "description": "Eurosky es tu hogar europeo en la Atmosphere" - }, - "selfhostedSocial": { - "name": "selfhosted.social", - "description": "Un hogar para creadores, entusiastas y curiosos" - }, - "northsky": { - "name": "Northsky", - "description": "Una cooperativa canadiense propiedad de los trabajadores" - }, - "tophhie": { - "name": "Tophhie", - "description": "Una comunidad acogedora y amable" - }, - "customPds": { - "name": "Usa un PDS personalizado", - "description": "¿Ya tienes un PDS? Introduce su dirección." - } - } - }, - "composer": { - "newHighlight": "Nuevo resaltado", - "newAnnotation": "Nueva anotación", - "newNote": "Nueva nota", - "saveHighlight": "Guardar resaltado", - "postAnnotation": "Publicar anotación", - "postNote": "Publicar nota", - "highlightHint": "Guardando un pasaje sin comentario. Añade texto debajo para convertirlo en una anotación.", - "addQuote": "+ Añadir una cita de la página", - "quotePlaceholder": "Pega o escribe el texto que estás anotando…", - "removeQuote": "Eliminar cita", - "thoughtsPlaceholder": "Añade tus reflexiones sobre este pasaje…", - "mindPlaceholder": "¿Qué tienes en mente?", - "tagsPlaceholder": "Añadir etiquetas…", - "contentWarning": "Advertencia de contenido", - "contentWarningCount": "Advertencia de contenido ({{count}})", - "cancel": "Cancelar", - "failedToPost": "Error al publicar", - "labels": { - "sexual": "Sexual", - "nudity": "Desnudez", - "violence": "Violencia", - "gore": "Gore", - "spam": "Spam", - "misleading": "Engañoso" - } - }, - "card": { - "addedTo": "Añadido a", - "addedToLower": "añadido a", - "and": "y", - "communityBookmark": "Marcador de la comunidad", - "openInSemble": "Abrir en Semble", - "deleteConfirm": "¿Eliminar este elemento?", - "hideContent": "Ocultar contenido", - "show": "Mostrar", - "edited": "(editado)", - "annotate": "Anotar", - "untitledBookmark": "Marcador sin título", - "addNotePlaceholder": "Añade tu nota para convertir este resaltado en una anotación…", - "addToCollectionTitle": "Añadir a la colección", - "annotateTitle": "Anotar este resaltado", - "editTitle": "Editar", - "deleteTitle": "Eliminar", - "report": "Reportar", - "muteUser": "Silenciar a {{handle}}", - "blockUser": "Bloquear a {{handle}}", - "convertToAnnotation": "Convertir en anotación", - "justNow": "hace un momento", - "labelDescriptions": { - "sexual": "Contenido sexual", - "nudity": "Desnudez", - "violence": "Violencia", - "gore": "Contenido gráfico", - "spam": "Spam", - "misleading": "Engañoso" - } - }, - "profileHoverCard": { - "viewProfile": "Ver perfil", - "notFound": "Perfil no encontrado" - }, - "replyList": { - "noReplies": "Aún no hay respuestas" - }, - "shareMenu": { - "sembleIntegration": "Integración con Semble", - "openOnSemble": "Abrir en Semble", - "copySembleLink": "Copiar enlace de Semble", - "copyLink": "Copiar enlace", - "shareViaApp": "Compartir vía app", - "copyUniversalLink": "Copiar enlace universal", - "moreOptions": "Más opciones…", - "copied": "¡Copiado!" - }, - "addToCollection": { - "title": "Añadir a la colección", - "loading": "Cargando colecciones…", - "collectionNameLabel": "Nombre de la colección", - "namePlaceholder": "Mi colección", - "descriptionLabel": "Descripción (opcional)", - "descriptionPlaceholder": "¿De qué trata esta colección?", - "iconLabel": "Icono", - "iconsTab": "Iconos", - "emojisTab": "Emojis", - "selected": "Seleccionado:", - "back": "Volver", - "create": "Crear", - "creating": "Creando…", - "newCollectionButton": "Nueva colección", - "createNewDescription": "Crear una nueva colección", - "none": "Aún no hay colecciones", - "done": "Hecho", - "failedLoad": "Error al cargar las colecciones", - "failedAdd": "Error al añadir a la colección", - "failedCreate": "Error al crear la colección" - }, - "editItem": { - "editAnnotation": "Editar anotación", - "editHighlight": "Editar resaltado", - "editBookmark": "Editar marcador", - "textLabel": "Texto", - "textPlaceholder": "Escribe tu anotación…", - "colorLabel": "Color", - "tagsLabel": "Etiquetas", - "tagPlaceholder": "Añadir una etiqueta…", - "contentWarning": "Advertencia de contenido", - "cancel": "Cancelar", - "save": "Guardar", - "saving": "Guardando…", - "failedSave": "Error al guardar los cambios. Por favor, inténtalo de nuevo.", - "titleLabel": "Título", - "titlePlaceholder": "Título del marcador", - "descriptionLabel": "Descripción", - "descriptionPlaceholder": "Descripción opcional…" - }, - "editCollection": { - "title": "Editar colección", - "nameLabel": "Nombre de la colección", - "namePlaceholder": "Mi colección", - "descriptionLabel": "Descripción (opcional)", - "descriptionPlaceholder": "¿De qué trata esta colección?", - "iconLabel": "Icono", - "iconsTab": "Iconos", - "emojisTab": "Emojis", - "selected": "Seleccionado:", - "cancel": "Cancelar", - "save": "Guardar cambios" + "resultCount": "{{count}}{{hasMore}} resultados para \"{{query}}\"", + "loadMore": "Cargar más" + }, + "notifications": { + "title": "Actividad", + "noActivity": "Aún no hay actividad", + "noActivityMessage": "Las interacciones con tu contenido aparecerán aquí.", + "likedAnnotation": "le gustó tu anotación", + "likedHighlight": "le dio me gusta a tu resaltado", + "likedBookmark": "le dio me gusta a tu marcador", + "likedReply": "le gustó tu respuesta", + "likedPost": "le dio me gusta a tu publicación", + "repliedToReply": "respondió a tu respuesta", + "repliedToAnnotation": "respondió a tu anotación", + "mentionedInAnnotation": "te mencionó en una anotación", + "followedYou": "te empezó a seguir", + "highlightedPage": "resaltó tu página", + "inReplyTo": "en respuesta a", + "aReply": "una respuesta", + "anAnnotation": "una anotación" + }, + "collections": { + "title": "Colecciones", + "subtitle": "Organiza tus anotaciones y resaltados", + "none": "Aún no hay colecciones", + "noneMessage": "Crea una colección para organizar tus resaltados y anotaciones.", + "createButton": "Crear colección", + "newTitle": "Nueva colección", + "editTitle": "Editar colección", + "namePlaceholder": "Mi colección", + "namePlaceholderEdit": "Nombre de la colección", + "nameLabel": "Nombre", + "iconLabel": "Icono", + "iconsTab": "Iconos", + "emojisTab": "Emojis", + "selectedIcon": "Seleccionado:", + "descriptionLabel": "Descripción (opcional)", + "descriptionPlaceholder": "¿Para qué es esta colección?", + "descriptionPlaceholderEdit": "¿De qué trata esta colección?", + "cancel": "Cancelar", + "create": "Crear colección", + "creating": "Creando…", + "save": "Guardar cambios", + "saving": "Guardando…", + "deleteConfirm": "¿Eliminar esta colección?", + "failedUpdate": "Error al actualizar la colección", + "errorUpdating": "Ocurrió un error al actualizar", + "itemCount_one": "{{count}} elemento", + "itemCount_many": "", + "itemCount_other": "{{count}} elementos" + }, + "collectionDetail": { + "by": "por", + "edit": "Editar colección", + "delete": "Eliminar colección", + "removeFromCollection": "Quitar de la colección", + "viewInSemble": "Ver en Semble", + "empty": "La colección está vacía", + "notFound": "Colección no encontrada", + "failedToLoad": "Error al cargar la colección", + "deleteConfirm": "¿Eliminar esta colección?", + "removeConfirm": "¿Eliminar de la colección?", + "backLink": "Colecciones" + }, + "profile": { + "notFound": "Usuario no encontrado", + "notFoundMessage": "Este perfil no existe o no se pudo cargar.", + "edit": "Editar", + "viewInBluesky": "Ver perfil en Bluesky", + "unblock": "Desbloquear a {{handle}}", + "block": "Bloquear a {{handle}}", + "unmute": "Desactivar silencio de {{handle}}", + "mute": "Silenciar a {{handle}}", + "report": "Reportar", + "accountLabeled": "Cuenta etiquetada: {{description}}", + "labelApplied": "Esta etiqueta fue aplicada por un servicio de moderación al que estás suscrito.", + "show": "Mostrar", + "hide": "Ocultar", + "blockedBanner": "Has bloqueado a {{handle}}", + "blockedMessage": "Su contenido está oculto de tus feeds.", + "mutedBanner": "Has silenciado a {{handle}}", + "mutedMessage": "Su contenido está oculto de tus feeds.", + "blockedByBanner": "{{handle}} te ha bloqueado. No puedes interactuar con su contenido.", + "unblock_action": "Desbloquear", + "unmute_action": "Activar sonido", + "emptyCollectionsOwn": "Aún no has creado ninguna colección.", + "emptyCollectionsOther": "Sin colecciones", + "itemCount_one": "{{count}} elemento", + "itemCount_many": "", + "itemCount_other": "{{count}} elementos", + "emptyTabOwn": "Tus {{tab}} aparecerán aquí.", + "emptyTabOther": "Aún no hay nada que ver aquí." + }, + "login": { + "signInWith": "Inicia sesión con tu", + "handleSuffix": "handle", + "handlePlaceholder": "handle.margin.cafe", + "connecting": "Conectando…", + "continue": "Continuar", + "createAccount": "Crear cuenta nueva", + "termsPrefix": "Al iniciar sesión, aceptas nuestros", + "termsLink": "Términos de servicio", + "termsAnd": "y", + "privacyLink": "Política de privacidad" + }, + "signUp": { + "title": "Crea tu cuenta", + "subtitle": "Margin se adhiere al", + "atProtocol": "AT Protocol", + "subtitleSuffix": ". Elige un proveedor para alojar tu cuenta.", + "customPdsTitle": "Usa un PDS personalizado", + "customPdsSubtitle": "Introduce la dirección del PDS que aloja tu cuenta.", + "pdsAddressLabel": "Dirección del PDS", + "pdsAddressPlaceholder": "pds.example.com", + "connecting": "Conectando…", + "back": "Volver", + "continue": "Continuar", + "invite": "Invitar", + "providerError": "No se pudo conectar con este proveedor. Por favor, inténtalo de nuevo.", + "customPdsError": "No se pudo conectar con ese PDS. Revisa la dirección.", + "providers": { + "margin": { + "description": "La forma más fácil de empezar", + "name": "Margin" + }, + "bluesky": { + "name": "Bluesky", + "description": "La comunidad más grande y popular" + }, + "blacksky": { + "name": "Blacksky", + "description": "Para la cultura — un espacio seguro para usuarios y aliados" + }, + "eurosky": { + "name": "Eurosky", + "description": "Eurosky es tu hogar europeo en la Atmosphere" + }, + "selfhostedSocial": { + "name": "selfhosted.social", + "description": "Un hogar para creadores, entusiastas y curiosos" + }, + "northsky": { + "name": "Northsky", + "description": "Una cooperativa canadiense propiedad de los trabajadores" + }, + "tophhie": { + "name": "Tophhie", + "description": "Una comunidad acogedora y amable" + }, + "customPds": { + "name": "Usa un PDS personalizado", + "description": "¿Ya tienes un PDS? Introduce su dirección." + } } + }, + "composer": { + "newHighlight": "Nuevo resaltado", + "newAnnotation": "Nueva anotación", + "newNote": "Nueva nota", + "saveHighlight": "Guardar resaltado", + "postAnnotation": "Publicar anotación", + "postNote": "Publicar nota", + "highlightHint": "Guardando un pasaje sin comentario. Añade texto debajo para convertirlo en una anotación.", + "addQuote": "+ Añadir una cita de la página", + "quotePlaceholder": "Pega o escribe el texto que estás anotando…", + "removeQuote": "Eliminar cita", + "thoughtsPlaceholder": "Añade tus reflexiones sobre este pasaje…", + "mindPlaceholder": "¿Qué tienes en mente?", + "tagsPlaceholder": "Añadir etiquetas…", + "contentWarning": "Advertencia de contenido", + "contentWarningCount": "Advertencia de contenido ({{count}})", + "cancel": "Cancelar", + "failedToPost": "Error al publicar", + "labels": { + "sexual": "Sexual", + "nudity": "Desnudez", + "violence": "Violencia", + "gore": "Gore", + "spam": "Spam", + "misleading": "Engañoso" + } + }, + "card": { + "addedTo": "Añadido a", + "addedToLower": "añadido a", + "and": "y", + "communityBookmark": "Marcador de la comunidad", + "openInSemble": "Abrir en Semble", + "deleteConfirm": "¿Eliminar este elemento?", + "hideContent": "Ocultar contenido", + "show": "Mostrar", + "edited": "(editado)", + "annotate": "Anotar", + "untitledBookmark": "Marcador sin título", + "addNotePlaceholder": "Añade tu nota para convertir este resaltado en una anotación…", + "addToCollectionTitle": "Añadir a la colección", + "annotateTitle": "Anotar este resaltado", + "editTitle": "Editar", + "deleteTitle": "Eliminar", + "report": "Reportar", + "muteUser": "Silenciar a {{handle}}", + "blockUser": "Bloquear a {{handle}}", + "convertToAnnotation": "Convertir en anotación", + "justNow": "hace un momento", + "labelDescriptions": { + "sexual": "Contenido sexual", + "nudity": "Desnudez", + "violence": "Violencia", + "gore": "Contenido gráfico", + "spam": "Spam", + "misleading": "Engañoso" + } + }, + "profileHoverCard": { + "viewProfile": "Ver perfil", + "notFound": "Perfil no encontrado" + }, + "replyList": { + "noReplies": "Aún no hay respuestas" + }, + "shareMenu": { + "sembleIntegration": "Integración con Semble", + "openOnSemble": "Abrir en Semble", + "copySembleLink": "Copiar enlace de Semble", + "copyLink": "Copiar enlace", + "shareViaApp": "Compartir vía app", + "copyUniversalLink": "Copiar enlace universal", + "moreOptions": "Más opciones…", + "copied": "¡Copiado!" + }, + "addToCollection": { + "title": "Añadir a la colección", + "loading": "Cargando colecciones…", + "collectionNameLabel": "Nombre de la colección", + "namePlaceholder": "Mi colección", + "descriptionLabel": "Descripción (opcional)", + "descriptionPlaceholder": "¿De qué trata esta colección?", + "iconLabel": "Icono", + "iconsTab": "Iconos", + "emojisTab": "Emojis", + "selected": "Seleccionado:", + "back": "Volver", + "create": "Crear", + "creating": "Creando…", + "newCollectionButton": "Nueva colección", + "createNewDescription": "Crear una nueva colección", + "none": "Aún no hay colecciones", + "done": "Hecho", + "failedLoad": "Error al cargar las colecciones", + "failedAdd": "Error al añadir a la colección", + "failedCreate": "Error al crear la colección" + }, + "editItem": { + "editAnnotation": "Editar anotación", + "editHighlight": "Editar resaltado", + "editBookmark": "Editar marcador", + "textLabel": "Texto", + "textPlaceholder": "Escribe tu anotación…", + "colorLabel": "Color", + "tagsLabel": "Etiquetas", + "tagPlaceholder": "Añadir una etiqueta…", + "contentWarning": "Advertencia de contenido", + "cancel": "Cancelar", + "save": "Guardar", + "saving": "Guardando…", + "failedSave": "Error al guardar los cambios. Por favor, inténtalo de nuevo.", + "titleLabel": "Título", + "titlePlaceholder": "Título del marcador", + "descriptionLabel": "Descripción", + "descriptionPlaceholder": "Descripción opcional…" + }, + "editCollection": { + "title": "Editar colección", + "nameLabel": "Nombre de la colección", + "namePlaceholder": "Mi colección", + "descriptionLabel": "Descripción (opcional)", + "descriptionPlaceholder": "¿De qué trata esta colección?", + "iconLabel": "Icono", + "iconsTab": "Iconos", + "emojisTab": "Emojis", + "selected": "Seleccionado:", + "cancel": "Cancelar", + "save": "Guardar cambios" + } } diff --git a/web/src/components/common/RichText.tsx b/web/src/components/common/RichText.tsx --- a/web/src/components/common/RichText.tsx +++ b/web/src/components/common/RichText.tsx @@ -61,7 +61,11 @@ } }; - const handleExternalClick = (e: React.MouseEvent, url: string) => { + const handleExternalClick = ( + e: React.MouseEvent, + url: string, + isBareUrl: boolean = false, + ) => { e.preventDefault(); e.stopPropagation(); @@ -78,7 +82,7 @@ return; } - if (preferences.disableExternalLinkWarning) { + if (isBareUrl || preferences.disableExternalLinkWarning) { window.open(url, "_blank", "noopener,noreferrer"); return; } @@ -110,7 +114,7 @@ target="_blank" rel="noopener noreferrer" className="text-primary-600 dark:text-primary-400 hover:underline break-all cursor-pointer" - onClick={(e) => handleExternalClick(e, part.text)} + onClick={(e) => handleExternalClick(e, part.text, true)} > {part.text} , diff --git a/web/src/views/auth/Login.tsx b/web/src/views/auth/Login.tsx --- a/web/src/views/auth/Login.tsx +++ b/web/src/views/auth/Login.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef } from "react"; -import { AtSign } from "lucide-react"; +import { AtSign, ShieldOff } from "lucide-react"; import { useTranslation } from "react-i18next"; import "../../i18n"; import SignUpModal from "../../components/modals/SignUpModal"; @@ -156,6 +156,48 @@ setLoading(false); } }; + + if (initialError === "banned") { + return ( +
+
+
+
+
+
+
+ +
+
+

+ {t("login.bannedTitle")} +

+

+ {t("login.bannedMessage")} +

+

+ {t("login.bannedAppeal")}{" "} + + hello@margin.at + + . +

+ +
+
+ ); + } return (
diff --git a/web/src/views/core/AdminModeration.tsx b/web/src/views/core/AdminModeration.tsx --- a/web/src/views/core/AdminModeration.tsx +++ b/web/src/views/core/AdminModeration.tsx @@ -9,6 +9,10 @@ adminCreateLabel, adminDeleteLabel, adminGetLabels, + adminBanAccount, + adminUnbanAccount, + adminGetBannedAccounts, + type BannedAccount, } from "../../api/client"; import type { ModerationReport, HydratedLabel } from "../../types"; import { @@ -24,6 +28,8 @@ Plus, Trash2, EyeOff, + UserX, + UserCheck, } from "lucide-react"; import { Avatar, EmptyState, Skeleton, Button } from "../../components/ui"; @@ -57,7 +63,7 @@ "misleading", ]; -type Tab = "reports" | "labels" | "actions"; +type Tab = "reports" | "labels" | "actions" | "bans"; export default function AdminModeration() { const { t } = useTranslation(); @@ -81,6 +87,13 @@ const [labelSubmitting, setLabelSubmitting] = useState(false); const [labelSuccess, setLabelSuccess] = useState(false); + const [bans, setBans] = useState([]); + const [banDid, setBanDid] = useState(""); + const [banReason, setBanReason] = useState(""); + const [banSubmitting, setBanSubmitting] = useState(false); + const [banSuccess, setBanSuccess] = useState(false); + const [unbanLoading, setUnbanLoading] = useState(null); + const loadReports = async (status: string) => { const data = await getAdminReports(status || undefined); setReports(data.items); @@ -91,6 +104,11 @@ const loadLabels = async () => { const data = await adminGetLabels(); setLabels(data.items || []); + }; + + const loadBans = async () => { + const data = await adminGetBannedAccounts(); + setBans(data.items || []); }; useEffect(() => { @@ -106,6 +124,7 @@ const handleTabChange = async (tab: Tab) => { setActiveTab(tab); if (tab === "labels") await loadLabels(); + if (tab === "bans") await loadBans(); }; const handleFilterChange = async (status: string) => { @@ -121,6 +140,36 @@ setExpandedReport(null); } setActionLoading(null); + }; + + const handleBanFromReport = async (did: string, reportId: number) => { + setActionLoading(reportId); + await adminBanAccount({ did }); + setActionLoading(null); + }; + + const handleBanAccount = async () => { + if (!banDid.trim()) return; + setBanSubmitting(true); + const success = await adminBanAccount({ + did: banDid.trim(), + reason: banReason.trim() || undefined, + }); + if (success) { + setBanDid(""); + setBanReason(""); + setBanSuccess(true); + setTimeout(() => setBanSuccess(false), 2000); + await loadBans(); + } + setBanSubmitting(false); + }; + + const handleUnban = async (did: string) => { + setUnbanLoading(did); + const success = await adminUnbanAccount(did); + if (success) setBans((prev) => prev.filter((b) => b.did !== did)); + setUnbanLoading(null); }; const handleCreateLabel = async () => { @@ -207,6 +256,11 @@ id: "labels" as Tab, label: t("adminModeration.tabs.labels"), icon: , + }, + { + id: "bans" as Tab, + label: "Bans", + icon: , }, ].map((tab) => ( +
)} @@ -551,6 +616,116 @@ ))}
)} + + )} + + {activeTab === "bans" && ( +
+
+

+ + Ban account +

+

+ Banned users cannot sign in and their content is hidden everywhere + on Margin. +

+ +
+
+ + setBanDid(e.target.value)} + placeholder="did:plc:..." + 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" + /> +
+ +
+ + setBanReason(e.target.value)} + placeholder="Reason for ban..." + 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" + /> +
+ +
+ + {banSuccess && ( + + Account banned + + )} +
+
+
+ +
+ {bans.length === 0 ? ( + } + title="No banned accounts" + message="Banned accounts will appear here." + /> + ) : ( +
+ {bans.map((ban) => ( +
+ +
+
+ + {ban.profile?.displayName || + (ban.profile?.handle && `@${ban.profile.handle}`) || + ban.did} + + + banned + +
+

+ {ban.reason ? `${ban.reason} · ` : ""} + {new Date(ban.bannedAt).toLocaleDateString()} +

+
+ +
+ ))} +
+ )} +
)} -- tangled.sh