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

Configure Feed

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

feat: UI revamp, implement microcosm, and security enhancements

#18

authored by

Scan and committed by
GitHub
568ad386 a71a8a08

+6484 -3216
+1
backend/cmd/server/main.go
··· 94 94 95 95 r.Get("/auth/login", oauthHandler.HandleLogin) 96 96 r.Post("/auth/start", oauthHandler.HandleStart) 97 + r.Post("/auth/signup", oauthHandler.HandleSignup) 97 98 r.Get("/auth/callback", oauthHandler.HandleCallback) 98 99 r.Post("/auth/logout", oauthHandler.HandleLogout) 99 100 r.Get("/auth/session", oauthHandler.HandleSession)
+14
backend/go.mod
··· 15 15 16 16 require ( 17 17 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 18 + github.com/fxamacker/cbor/v2 v2.9.0 // indirect 19 + github.com/ipfs/go-cid v0.6.0 // indirect 20 + github.com/klauspost/cpuid/v2 v2.0.9 // indirect 21 + github.com/minio/sha256-simd v1.0.0 // indirect 22 + github.com/mr-tron/base58 v1.2.0 // indirect 23 + github.com/multiformats/go-base32 v0.0.3 // indirect 24 + github.com/multiformats/go-base36 v0.1.0 // indirect 25 + github.com/multiformats/go-multibase v0.2.0 // indirect 26 + github.com/multiformats/go-multihash v0.2.3 // indirect 27 + github.com/multiformats/go-varint v0.1.0 // indirect 18 28 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 29 + github.com/spaolacci/murmur3 v1.1.0 // indirect 19 30 github.com/stretchr/testify v1.10.0 // indirect 31 + github.com/x448/float16 v0.8.4 // indirect 20 32 golang.org/x/crypto v0.35.0 // indirect 33 + golang.org/x/sys v0.30.0 // indirect 21 34 golang.org/x/text v0.32.0 // indirect 35 + lukechampine.com/blake3 v1.1.6 // indirect 22 36 )
+29
backend/go.sum
··· 1 1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 2 2 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 + github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= 4 + github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= 3 5 github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= 4 6 github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= 5 7 github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= ··· 10 12 github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 11 13 github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 12 14 github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 15 + github.com/ipfs/go-cid v0.6.0 h1:DlOReBV1xhHBhhfy/gBNNTSyfOM6rLiIx9J7A4DGf30= 16 + github.com/ipfs/go-cid v0.6.0/go.mod h1:NC4kS1LZjzfhK40UGmpXv5/qD2kcMzACYJNntCUiDhQ= 13 17 github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 14 18 github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 19 + github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= 20 + github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= 21 + github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= 15 22 github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 16 23 github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 17 24 github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= 18 25 github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 26 + github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g= 27 + github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM= 28 + github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= 29 + github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= 30 + github.com/multiformats/go-base32 v0.0.3 h1:tw5+NhuwaOjJCC5Pp82QuXbrmLzWg7uxlMFp8Nq/kkI= 31 + github.com/multiformats/go-base32 v0.0.3/go.mod h1:pLiuGC8y0QR3Ue4Zug5UzK9LjgbkL8NSQj0zQ5Nz/AA= 32 + github.com/multiformats/go-base36 v0.1.0 h1:JR6TyF7JjGd3m6FbLU2cOxhC0Li8z8dLNGQ89tUg4F4= 33 + github.com/multiformats/go-base36 v0.1.0/go.mod h1:kFGE83c6s80PklsHO9sRn2NCoffoRdUUOENyW/Vv6sM= 34 + github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g= 35 + github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk= 36 + github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= 37 + github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= 38 + github.com/multiformats/go-varint v0.1.0 h1:i2wqFp4sdl3IcIxfAonHQV9qU5OsZ4Ts9IOoETFs5dI= 39 + github.com/multiformats/go-varint v0.1.0/go.mod h1:5KVAVXegtfmNQQm/lCY+ATvDzvJJhSkUlGQV9wgObdI= 19 40 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 20 41 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 42 + github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= 43 + github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 21 44 github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 22 45 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 46 + github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= 47 + github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 23 48 golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= 24 49 golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= 25 50 golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8= 26 51 golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU= 52 + golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= 53 + golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 27 54 golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= 28 55 golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= 29 56 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 30 57 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 58 + lukechampine.com/blake3 v1.1.6 h1:H3cROdztr7RCfoaTpGZFQsrqvweFLrqS73j7L7cmR5c= 59 + lukechampine.com/blake3 v1.1.6/go.mod h1:tkKEOtDkNtklkXtLNEOGNq5tcV90tJiA1vAA12R78LA=
+137 -16
backend/internal/api/handler.go
··· 68 68 r.Post("/sync", h.SyncAll) 69 69 70 70 r.Get("/targets", h.GetByTarget) 71 + r.Get("/discover", h.DiscoverForURL) 71 72 72 73 r.Get("/users/{did}/annotations", h.GetUserAnnotations) 73 74 r.Get("/users/{did}/highlights", h.GetUserHighlights) ··· 139 140 140 141 func (h *Handler) GetFeed(w http.ResponseWriter, r *http.Request) { 141 142 limit := parseIntParam(r, "limit", 50) 143 + offset := parseIntParam(r, "offset", 0) 142 144 tag := r.URL.Query().Get("tag") 143 145 creator := r.URL.Query().Get("creator") 144 146 feedType := r.URL.Query().Get("type") ··· 147 149 148 150 if viewerDID != "" && (creator == viewerDID || (creator == "" && tag == "" && feedType == "my-feed")) { 149 151 if creator == viewerDID { 150 - h.serveUserFeedFromPDS(w, r, viewerDID, tag, limit) 152 + h.serveUserFeedFromPDS(w, r, viewerDID, tag, limit, offset) 151 153 return 152 154 } 153 155 } ··· 159 161 var err error 160 162 161 163 motivation := r.URL.Query().Get("motivation") 164 + 165 + fetchLimit := limit + offset 162 166 163 167 if tag != "" { 164 168 if creator != "" { 165 169 if motivation == "" || motivation == "commenting" { 166 - annotations, _ = h.db.GetAnnotationsByTagAndAuthor(tag, creator, limit, 0) 170 + annotations, _ = h.db.GetAnnotationsByTagAndAuthor(tag, creator, fetchLimit, 0) 167 171 } 168 172 if motivation == "" || motivation == "highlighting" { 169 - highlights, _ = h.db.GetHighlightsByTagAndAuthor(tag, creator, limit, 0) 173 + highlights, _ = h.db.GetHighlightsByTagAndAuthor(tag, creator, fetchLimit, 0) 170 174 } 171 175 if motivation == "" || motivation == "bookmarking" { 172 - bookmarks, _ = h.db.GetBookmarksByTagAndAuthor(tag, creator, limit, 0) 176 + bookmarks, _ = h.db.GetBookmarksByTagAndAuthor(tag, creator, fetchLimit, 0) 173 177 } 174 178 collectionItems = []db.CollectionItem{} 175 179 } else { 176 180 if motivation == "" || motivation == "commenting" { 177 - annotations, _ = h.db.GetAnnotationsByTag(tag, limit, 0) 181 + annotations, _ = h.db.GetAnnotationsByTag(tag, fetchLimit, 0) 178 182 } 179 183 if motivation == "" || motivation == "highlighting" { 180 - highlights, _ = h.db.GetHighlightsByTag(tag, limit, 0) 184 + highlights, _ = h.db.GetHighlightsByTag(tag, fetchLimit, 0) 181 185 } 182 186 if motivation == "" || motivation == "bookmarking" { 183 - bookmarks, _ = h.db.GetBookmarksByTag(tag, limit, 0) 187 + bookmarks, _ = h.db.GetBookmarksByTag(tag, fetchLimit, 0) 184 188 } 185 189 collectionItems = []db.CollectionItem{} 186 190 } 187 191 } else if creator != "" { 188 192 if motivation == "" || motivation == "commenting" { 189 - annotations, _ = h.db.GetAnnotationsByAuthor(creator, limit, 0) 193 + annotations, _ = h.db.GetAnnotationsByAuthor(creator, fetchLimit, 0) 190 194 } 191 195 if motivation == "" || motivation == "highlighting" { 192 - highlights, _ = h.db.GetHighlightsByAuthor(creator, limit, 0) 196 + highlights, _ = h.db.GetHighlightsByAuthor(creator, fetchLimit, 0) 193 197 } 194 198 if motivation == "" || motivation == "bookmarking" { 195 - bookmarks, _ = h.db.GetBookmarksByAuthor(creator, limit, 0) 199 + bookmarks, _ = h.db.GetBookmarksByAuthor(creator, fetchLimit, 0) 196 200 } 197 201 collectionItems = []db.CollectionItem{} 198 202 } else { 199 203 if motivation == "" || motivation == "commenting" { 200 - annotations, _ = h.db.GetRecentAnnotations(limit, 0) 204 + annotations, _ = h.db.GetRecentAnnotations(fetchLimit, 0) 201 205 } 202 206 if motivation == "" || motivation == "highlighting" { 203 - highlights, _ = h.db.GetRecentHighlights(limit, 0) 207 + highlights, _ = h.db.GetRecentHighlights(fetchLimit, 0) 204 208 } 205 209 if motivation == "" || motivation == "bookmarking" { 206 - bookmarks, _ = h.db.GetRecentBookmarks(limit, 0) 210 + bookmarks, _ = h.db.GetRecentBookmarks(fetchLimit, 0) 207 211 } 208 212 if motivation == "" { 209 - collectionItems, err = h.db.GetRecentCollectionItems(limit, 0) 213 + collectionItems, err = h.db.GetRecentCollectionItems(fetchLimit, 0) 210 214 if err != nil { 211 215 log.Printf("Error fetching collection items: %v\n", err) 212 216 } ··· 283 287 sortFeed(feed) 284 288 } 285 289 290 + if offset < len(feed) { 291 + feed = feed[offset:] 292 + } else { 293 + feed = []interface{}{} 294 + } 295 + 286 296 if len(feed) > limit { 287 297 feed = feed[:limit] 288 298 } ··· 296 306 }) 297 307 } 298 308 299 - func (h *Handler) serveUserFeedFromPDS(w http.ResponseWriter, r *http.Request, did, tag string, limit int) { 309 + func (h *Handler) serveUserFeedFromPDS(w http.ResponseWriter, r *http.Request, did, tag string, limit, offset int) { 300 310 var wg sync.WaitGroup 301 311 var rawAnnos, rawHighs, rawBooks []interface{} 302 312 var errAnnos, errHighs, errBooks error 303 313 304 - fetchLimit := limit * 2 314 + fetchLimit := limit + offset 305 315 if fetchLimit < 50 { 306 316 fetchLimit = 50 307 317 } ··· 413 423 } 414 424 415 425 sortFeed(feed) 426 + 427 + if offset < len(feed) { 428 + feed = feed[offset:] 429 + } else { 430 + feed = []interface{}{} 431 + } 416 432 417 433 if len(feed) > limit { 418 434 feed = feed[:limit] ··· 628 644 "annotations": enrichedAnnotations, 629 645 "highlights": enrichedHighlights, 630 646 "bookmarks": enrichedBookmarks, 647 + }) 648 + } 649 + 650 + func (h *Handler) DiscoverForURL(w http.ResponseWriter, r *http.Request) { 651 + source := r.URL.Query().Get("source") 652 + if source == "" { 653 + source = r.URL.Query().Get("url") 654 + } 655 + if source == "" { 656 + http.Error(w, "source or url parameter required", http.StatusBadRequest) 657 + return 658 + } 659 + 660 + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) 661 + defer cancel() 662 + 663 + annotations, highlights, bookmarks, err := ConstellationClient.GetAllItemsForURL(ctx, source) 664 + if err != nil { 665 + log.Printf("Constellation discover error, falling back to local: %v", err) 666 + h.GetByTarget(w, r) 667 + return 668 + } 669 + 670 + var annotationURIs, highlightURIs, bookmarkURIs []string 671 + seenURIs := make(map[string]bool) 672 + 673 + for _, link := range annotations { 674 + if !seenURIs[link.URI] { 675 + annotationURIs = append(annotationURIs, link.URI) 676 + seenURIs[link.URI] = true 677 + } 678 + } 679 + for _, link := range highlights { 680 + if !seenURIs[link.URI] { 681 + highlightURIs = append(highlightURIs, link.URI) 682 + seenURIs[link.URI] = true 683 + } 684 + } 685 + for _, link := range bookmarks { 686 + if !seenURIs[link.URI] { 687 + bookmarkURIs = append(bookmarkURIs, link.URI) 688 + seenURIs[link.URI] = true 689 + } 690 + } 691 + 692 + localAnnotations, _ := h.db.GetAnnotationsByURIs(annotationURIs) 693 + localHighlights, _ := h.db.GetHighlightsByURIs(highlightURIs) 694 + localBookmarks, _ := h.db.GetBookmarksByURIs(bookmarkURIs) 695 + 696 + urlHash := db.HashURL(source) 697 + dbAnnotations, _ := h.db.GetAnnotationsByTargetHash(urlHash, 100, 0) 698 + dbHighlights, _ := h.db.GetHighlightsByTargetHash(urlHash, 100, 0) 699 + dbBookmarks, _ := h.db.GetBookmarksByTargetHash(urlHash, 100, 0) 700 + 701 + annoMap := make(map[string]db.Annotation) 702 + for _, a := range localAnnotations { 703 + annoMap[a.URI] = a 704 + } 705 + for _, a := range dbAnnotations { 706 + annoMap[a.URI] = a 707 + } 708 + 709 + highMap := make(map[string]db.Highlight) 710 + for _, h := range localHighlights { 711 + highMap[h.URI] = h 712 + } 713 + for _, h := range dbHighlights { 714 + highMap[h.URI] = h 715 + } 716 + 717 + bookMap := make(map[string]db.Bookmark) 718 + for _, b := range localBookmarks { 719 + bookMap[b.URI] = b 720 + } 721 + for _, b := range dbBookmarks { 722 + bookMap[b.URI] = b 723 + } 724 + 725 + var mergedAnnotations []db.Annotation 726 + for _, a := range annoMap { 727 + mergedAnnotations = append(mergedAnnotations, a) 728 + } 729 + var mergedHighlights []db.Highlight 730 + for _, h := range highMap { 731 + mergedHighlights = append(mergedHighlights, h) 732 + } 733 + var mergedBookmarks []db.Bookmark 734 + for _, b := range bookMap { 735 + mergedBookmarks = append(mergedBookmarks, b) 736 + } 737 + 738 + viewerDID := h.getViewerDID(r) 739 + enrichedAnnotations, _ := hydrateAnnotations(h.db, mergedAnnotations, viewerDID) 740 + enrichedHighlights, _ := hydrateHighlights(h.db, mergedHighlights, viewerDID) 741 + enrichedBookmarks, _ := hydrateBookmarks(h.db, mergedBookmarks, viewerDID) 742 + 743 + w.Header().Set("Content-Type", "application/json") 744 + json.NewEncoder(w).Encode(map[string]interface{}{ 745 + "@context": "http://www.w3.org/ns/anno.jsonld", 746 + "source": source, 747 + "sourceHash": urlHash, 748 + "annotations": enrichedAnnotations, 749 + "highlights": enrichedHighlights, 750 + "bookmarks": enrichedBookmarks, 751 + "networkDiscovered": len(annotations) + len(highlights) + len(bookmarks), 631 752 }) 632 753 } 633 754
+77 -63
backend/internal/api/hydration.go
··· 1 1 package api 2 2 3 3 import ( 4 + "context" 4 5 "encoding/json" 5 6 "fmt" 6 7 "log" ··· 10 11 "sync" 11 12 "time" 12 13 14 + "margin.at/internal/constellation" 13 15 "margin.at/internal/db" 14 16 ) 15 17 16 18 var ( 17 - Cache ProfileCache = NewInMemoryCache(5 * time.Minute) 19 + Cache ProfileCache = NewInMemoryCache(5 * time.Minute) 20 + ConstellationClient *constellation.Client = constellation.NewClient() // Enabled by default 18 21 ) 22 + 23 + func init() { 24 + log.Printf("Constellation client initialized: %s", constellation.DefaultBaseURL) 25 + } 19 26 20 27 type Author struct { 21 28 DID string `json:"did"` ··· 145 152 ReadAt *time.Time `json:"readAt,omitempty"` 146 153 } 147 154 148 - func hydrateAnnotations(database *db.DB, annotations []db.Annotation, viewerDID string) ([]APIAnnotation, error) { 149 - if len(annotations) == 0 { 150 - return []APIAnnotation{}, nil 151 - } 155 + func fetchCounts(ctx context.Context, database *db.DB, uris []string, viewerDID string) (likeCounts, replyCounts map[string]int, viewerLikes map[string]bool) { 156 + likeCounts = make(map[string]int) 157 + replyCounts = make(map[string]int) 158 + viewerLikes = make(map[string]bool) 152 159 153 - profiles := fetchProfilesForDIDs(collectDIDs(annotations, func(a db.Annotation) string { return a.AuthorDID })) 154 - 155 - var likeCounts map[string]int 156 - var replyCounts map[string]int 157 - var viewerLikes map[string]bool 160 + if len(uris) == 0 { 161 + return 162 + } 158 163 159 164 if database != nil { 160 - uris := make([]string, len(annotations)) 161 - for i, a := range annotations { 162 - uris[i] = a.URI 163 - } 164 - 165 165 likeCounts, _ = database.GetLikeCounts(uris) 166 166 replyCounts, _ = database.GetReplyCounts(uris) 167 167 if viewerDID != "" { ··· 169 169 } 170 170 } 171 171 172 + if ConstellationClient != nil && len(uris) <= 5 { 173 + constellationCounts, err := ConstellationClient.GetCountsBatch(ctx, uris) 174 + if err != nil { 175 + log.Printf("Constellation fetch error (non-fatal): %v", err) 176 + return 177 + } 178 + 179 + for uri, counts := range constellationCounts { 180 + if counts.LikeCount > likeCounts[uri] { 181 + likeCounts[uri] = counts.LikeCount 182 + } 183 + if counts.ReplyCount > replyCounts[uri] { 184 + replyCounts[uri] = counts.ReplyCount 185 + } 186 + } 187 + } 188 + 189 + return 190 + } 191 + 192 + func hydrateAnnotations(database *db.DB, annotations []db.Annotation, viewerDID string) ([]APIAnnotation, error) { 193 + if len(annotations) == 0 { 194 + return []APIAnnotation{}, nil 195 + } 196 + 197 + profiles := fetchProfilesForDIDs(collectDIDs(annotations, func(a db.Annotation) string { return a.AuthorDID })) 198 + 199 + uris := make([]string, len(annotations)) 200 + for i, a := range annotations { 201 + uris[i] = a.URI 202 + } 203 + 204 + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) 205 + defer cancel() 206 + likeCounts, replyCounts, viewerLikes := fetchCounts(ctx, database, uris, viewerDID) 207 + 172 208 result := make([]APIAnnotation, len(annotations)) 173 209 for i, a := range annotations { 174 210 var body *APIBody ··· 228 264 IndexedAt: a.IndexedAt, 229 265 } 230 266 231 - if database != nil { 232 - result[i].LikeCount = likeCounts[a.URI] 233 - result[i].ReplyCount = replyCounts[a.URI] 234 - if viewerLikes != nil && viewerLikes[a.URI] { 235 - result[i].ViewerHasLiked = true 236 - } 267 + result[i].LikeCount = likeCounts[a.URI] 268 + result[i].ReplyCount = replyCounts[a.URI] 269 + if viewerLikes != nil && viewerLikes[a.URI] { 270 + result[i].ViewerHasLiked = true 237 271 } 238 272 } 239 273 ··· 247 281 248 282 profiles := fetchProfilesForDIDs(collectDIDs(highlights, func(h db.Highlight) string { return h.AuthorDID })) 249 283 250 - var likeCounts map[string]int 251 - var replyCounts map[string]int 252 - var viewerLikes map[string]bool 253 - 254 - if database != nil { 255 - uris := make([]string, len(highlights)) 256 - for i, h := range highlights { 257 - uris[i] = h.URI 258 - } 259 - 260 - likeCounts, _ = database.GetLikeCounts(uris) 261 - replyCounts, _ = database.GetReplyCounts(uris) 262 - if viewerDID != "" { 263 - viewerLikes, _ = database.GetViewerLikes(viewerDID, uris) 264 - } 284 + uris := make([]string, len(highlights)) 285 + for i, h := range highlights { 286 + uris[i] = h.URI 265 287 } 288 + 289 + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) 290 + defer cancel() 291 + likeCounts, replyCounts, viewerLikes := fetchCounts(ctx, database, uris, viewerDID) 266 292 267 293 result := make([]APIHighlight, len(highlights)) 268 294 for i, h := range highlights { ··· 307 333 CID: cid, 308 334 } 309 335 310 - if database != nil { 311 - result[i].LikeCount = likeCounts[h.URI] 312 - result[i].ReplyCount = replyCounts[h.URI] 313 - if viewerLikes != nil && viewerLikes[h.URI] { 314 - result[i].ViewerHasLiked = true 315 - } 336 + result[i].LikeCount = likeCounts[h.URI] 337 + result[i].ReplyCount = replyCounts[h.URI] 338 + if viewerLikes != nil && viewerLikes[h.URI] { 339 + result[i].ViewerHasLiked = true 316 340 } 317 341 } 318 342 ··· 326 350 327 351 profiles := fetchProfilesForDIDs(collectDIDs(bookmarks, func(b db.Bookmark) string { return b.AuthorDID })) 328 352 329 - var likeCounts map[string]int 330 - var replyCounts map[string]int 331 - var viewerLikes map[string]bool 353 + uris := make([]string, len(bookmarks)) 354 + for i, b := range bookmarks { 355 + uris[i] = b.URI 356 + } 332 357 333 - if database != nil { 334 - uris := make([]string, len(bookmarks)) 335 - for i, b := range bookmarks { 336 - uris[i] = b.URI 337 - } 338 - 339 - likeCounts, _ = database.GetLikeCounts(uris) 340 - replyCounts, _ = database.GetReplyCounts(uris) 341 - if viewerDID != "" { 342 - viewerLikes, _ = database.GetViewerLikes(viewerDID, uris) 343 - } 344 - } 358 + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) 359 + defer cancel() 360 + likeCounts, replyCounts, viewerLikes := fetchCounts(ctx, database, uris, viewerDID) 345 361 346 362 result := make([]APIBookmark, len(bookmarks)) 347 363 for i, b := range bookmarks { ··· 376 392 CreatedAt: b.CreatedAt, 377 393 CID: cid, 378 394 } 379 - if database != nil { 380 - result[i].LikeCount = likeCounts[b.URI] 381 - result[i].ReplyCount = replyCounts[b.URI] 382 - if viewerLikes != nil && viewerLikes[b.URI] { 383 - result[i].ViewerHasLiked = true 384 - } 395 + result[i].LikeCount = likeCounts[b.URI] 396 + result[i].ReplyCount = replyCounts[b.URI] 397 + if viewerLikes != nil && viewerLikes[b.URI] { 398 + result[i].ViewerHasLiked = true 385 399 } 386 400 } 387 401
+16 -16
backend/internal/api/og.go
··· 875 875 height := 630 876 876 padding := 100 877 877 878 - bgPrimary := color.RGBA{12, 10, 20, 255} 879 - accent := color.RGBA{168, 85, 247, 255} 880 - textPrimary := color.RGBA{244, 240, 255, 255} 881 - textSecondary := color.RGBA{168, 158, 200, 255} 882 - border := color.RGBA{45, 38, 64, 255} 878 + bgPrimary := color.RGBA{10, 10, 13, 255} 879 + accent := color.RGBA{149, 122, 134, 255} 880 + textPrimary := color.RGBA{234, 234, 238, 255} 881 + textSecondary := color.RGBA{168, 164, 171, 255} 882 + border := color.RGBA{42, 40, 46, 255} 883 883 884 884 img := image.NewRGBA(image.Rect(0, 0, width, height)) 885 885 ··· 1118 1118 height := 630 1119 1119 padding := 120 1120 1120 1121 - bgPrimary := color.RGBA{12, 10, 20, 255} 1122 - accent := color.RGBA{168, 85, 247, 255} 1123 - textPrimary := color.RGBA{244, 240, 255, 255} 1124 - textSecondary := color.RGBA{168, 158, 200, 255} 1125 - textTertiary := color.RGBA{107, 95, 138, 255} 1126 - border := color.RGBA{45, 38, 64, 255} 1121 + bgPrimary := color.RGBA{10, 10, 13, 255} 1122 + accent := color.RGBA{149, 122, 134, 255} 1123 + textPrimary := color.RGBA{234, 234, 238, 255} 1124 + textSecondary := color.RGBA{168, 164, 171, 255} 1125 + textTertiary := color.RGBA{107, 103, 112, 255} 1126 + border := color.RGBA{42, 40, 46, 255} 1127 1127 1128 1128 img := image.NewRGBA(image.Rect(0, 0, width, height)) 1129 1129 ··· 1220 1220 height := 630 1221 1221 padding := 100 1222 1222 1223 - bgPrimary := color.RGBA{12, 10, 20, 255} 1224 - accent := color.RGBA{250, 204, 21, 255} 1225 - textPrimary := color.RGBA{244, 240, 255, 255} 1226 - textSecondary := color.RGBA{168, 158, 200, 255} 1227 - border := color.RGBA{45, 38, 64, 255} 1223 + bgPrimary := color.RGBA{10, 10, 13, 255} 1224 + accent := color.RGBA{149, 122, 134, 255} 1225 + textPrimary := color.RGBA{234, 234, 238, 255} 1226 + textSecondary := color.RGBA{168, 164, 171, 255} 1227 + border := color.RGBA{42, 40, 46, 255} 1228 1228 1229 1229 img := image.NewRGBA(image.Rect(0, 0, width, height)) 1230 1230
+341
backend/internal/constellation/client.go
··· 1 + package constellation 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "net/http" 8 + "net/url" 9 + "sync" 10 + "time" 11 + ) 12 + 13 + const ( 14 + DefaultBaseURL = "https://constellation.microcosm.blue" 15 + DefaultTimeout = 5 * time.Second 16 + UserAgent = "Margin (margin.at)" 17 + ) 18 + 19 + type Client struct { 20 + baseURL string 21 + httpClient *http.Client 22 + } 23 + 24 + func NewClient() *Client { 25 + return &Client{ 26 + baseURL: DefaultBaseURL, 27 + httpClient: &http.Client{ 28 + Timeout: DefaultTimeout, 29 + }, 30 + } 31 + } 32 + 33 + func NewClientWithURL(baseURL string) *Client { 34 + return &Client{ 35 + baseURL: baseURL, 36 + httpClient: &http.Client{ 37 + Timeout: DefaultTimeout, 38 + }, 39 + } 40 + } 41 + 42 + type CountResponse struct { 43 + Total int `json:"total"` 44 + } 45 + 46 + type Link struct { 47 + URI string `json:"uri"` 48 + Collection string `json:"collection"` 49 + DID string `json:"did"` 50 + Path string `json:"path"` 51 + } 52 + 53 + type LinksResponse struct { 54 + Links []Link `json:"links"` 55 + Cursor string `json:"cursor,omitempty"` 56 + } 57 + 58 + func (c *Client) GetLikeCount(ctx context.Context, subjectURI string) (int, error) { 59 + params := url.Values{} 60 + params.Set("target", subjectURI) 61 + params.Set("collection", "at.margin.like") 62 + params.Set("path", ".subject.uri") 63 + 64 + endpoint := fmt.Sprintf("%s/links/count/distinct-dids?%s", c.baseURL, params.Encode()) 65 + 66 + req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) 67 + if err != nil { 68 + return 0, fmt.Errorf("failed to create request: %w", err) 69 + } 70 + req.Header.Set("User-Agent", UserAgent) 71 + 72 + resp, err := c.httpClient.Do(req) 73 + if err != nil { 74 + return 0, fmt.Errorf("request failed: %w", err) 75 + } 76 + defer resp.Body.Close() 77 + 78 + if resp.StatusCode != http.StatusOK { 79 + return 0, fmt.Errorf("unexpected status code: %d", resp.StatusCode) 80 + } 81 + 82 + var countResp CountResponse 83 + if err := json.NewDecoder(resp.Body).Decode(&countResp); err != nil { 84 + return 0, fmt.Errorf("failed to decode response: %w", err) 85 + } 86 + 87 + return countResp.Total, nil 88 + } 89 + 90 + func (c *Client) GetReplyCount(ctx context.Context, rootURI string) (int, error) { 91 + params := url.Values{} 92 + params.Set("target", rootURI) 93 + params.Set("collection", "at.margin.reply") 94 + params.Set("path", ".root.uri") 95 + 96 + endpoint := fmt.Sprintf("%s/links/count?%s", c.baseURL, params.Encode()) 97 + 98 + req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) 99 + if err != nil { 100 + return 0, fmt.Errorf("failed to create request: %w", err) 101 + } 102 + req.Header.Set("User-Agent", UserAgent) 103 + 104 + resp, err := c.httpClient.Do(req) 105 + if err != nil { 106 + return 0, fmt.Errorf("request failed: %w", err) 107 + } 108 + defer resp.Body.Close() 109 + 110 + if resp.StatusCode != http.StatusOK { 111 + return 0, fmt.Errorf("unexpected status code: %d", resp.StatusCode) 112 + } 113 + 114 + var countResp CountResponse 115 + if err := json.NewDecoder(resp.Body).Decode(&countResp); err != nil { 116 + return 0, fmt.Errorf("failed to decode response: %w", err) 117 + } 118 + 119 + return countResp.Total, nil 120 + } 121 + 122 + type CountsResult struct { 123 + LikeCount int 124 + ReplyCount int 125 + } 126 + 127 + func (c *Client) GetCountsBatch(ctx context.Context, uris []string) (map[string]CountsResult, error) { 128 + if len(uris) == 0 { 129 + return map[string]CountsResult{}, nil 130 + } 131 + 132 + results := make(map[string]CountsResult) 133 + var mu sync.Mutex 134 + var wg sync.WaitGroup 135 + 136 + semaphore := make(chan struct{}, 10) 137 + 138 + for _, uri := range uris { 139 + wg.Add(1) 140 + go func(u string) { 141 + defer wg.Done() 142 + semaphore <- struct{}{} 143 + defer func() { <-semaphore }() 144 + 145 + likeCount, _ := c.GetLikeCount(ctx, u) 146 + replyCount, _ := c.GetReplyCount(ctx, u) 147 + 148 + mu.Lock() 149 + results[u] = CountsResult{ 150 + LikeCount: likeCount, 151 + ReplyCount: replyCount, 152 + } 153 + mu.Unlock() 154 + }(uri) 155 + } 156 + 157 + wg.Wait() 158 + return results, nil 159 + } 160 + 161 + func (c *Client) GetAnnotationsForURL(ctx context.Context, targetURL string) ([]Link, error) { 162 + params := url.Values{} 163 + params.Set("target", targetURL) 164 + params.Set("collection", "at.margin.annotation") 165 + params.Set("path", ".target.source") 166 + 167 + endpoint := fmt.Sprintf("%s/links?%s", c.baseURL, params.Encode()) 168 + 169 + req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) 170 + if err != nil { 171 + return nil, fmt.Errorf("failed to create request: %w", err) 172 + } 173 + req.Header.Set("User-Agent", UserAgent) 174 + 175 + resp, err := c.httpClient.Do(req) 176 + if err != nil { 177 + return nil, fmt.Errorf("request failed: %w", err) 178 + } 179 + defer resp.Body.Close() 180 + 181 + if resp.StatusCode != http.StatusOK { 182 + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) 183 + } 184 + 185 + var linksResp LinksResponse 186 + if err := json.NewDecoder(resp.Body).Decode(&linksResp); err != nil { 187 + return nil, fmt.Errorf("failed to decode response: %w", err) 188 + } 189 + 190 + return linksResp.Links, nil 191 + } 192 + 193 + func (c *Client) GetHighlightsForURL(ctx context.Context, targetURL string) ([]Link, error) { 194 + params := url.Values{} 195 + params.Set("target", targetURL) 196 + params.Set("collection", "at.margin.highlight") 197 + params.Set("path", ".target.source") 198 + 199 + endpoint := fmt.Sprintf("%s/links?%s", c.baseURL, params.Encode()) 200 + 201 + req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) 202 + if err != nil { 203 + return nil, fmt.Errorf("failed to create request: %w", err) 204 + } 205 + req.Header.Set("User-Agent", UserAgent) 206 + 207 + resp, err := c.httpClient.Do(req) 208 + if err != nil { 209 + return nil, fmt.Errorf("request failed: %w", err) 210 + } 211 + defer resp.Body.Close() 212 + 213 + if resp.StatusCode != http.StatusOK { 214 + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) 215 + } 216 + 217 + var linksResp LinksResponse 218 + if err := json.NewDecoder(resp.Body).Decode(&linksResp); err != nil { 219 + return nil, fmt.Errorf("failed to decode response: %w", err) 220 + } 221 + 222 + return linksResp.Links, nil 223 + } 224 + 225 + func (c *Client) GetBookmarksForURL(ctx context.Context, targetURL string) ([]Link, error) { 226 + params := url.Values{} 227 + params.Set("target", targetURL) 228 + params.Set("collection", "at.margin.bookmark") 229 + params.Set("path", ".source") 230 + 231 + endpoint := fmt.Sprintf("%s/links?%s", c.baseURL, params.Encode()) 232 + 233 + req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) 234 + if err != nil { 235 + return nil, fmt.Errorf("failed to create request: %w", err) 236 + } 237 + req.Header.Set("User-Agent", UserAgent) 238 + 239 + resp, err := c.httpClient.Do(req) 240 + if err != nil { 241 + return nil, fmt.Errorf("request failed: %w", err) 242 + } 243 + defer resp.Body.Close() 244 + 245 + if resp.StatusCode != http.StatusOK { 246 + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) 247 + } 248 + 249 + var linksResp LinksResponse 250 + if err := json.NewDecoder(resp.Body).Decode(&linksResp); err != nil { 251 + return nil, fmt.Errorf("failed to decode response: %w", err) 252 + } 253 + 254 + return linksResp.Links, nil 255 + } 256 + 257 + func (c *Client) GetAllItemsForURL(ctx context.Context, targetURL string) (annotations, highlights, bookmarks []Link, err error) { 258 + var wg sync.WaitGroup 259 + var mu sync.Mutex 260 + var errs []error 261 + 262 + wg.Add(3) 263 + 264 + go func() { 265 + defer wg.Done() 266 + links, e := c.GetAnnotationsForURL(ctx, targetURL) 267 + mu.Lock() 268 + defer mu.Unlock() 269 + if e != nil { 270 + errs = append(errs, e) 271 + } else { 272 + annotations = links 273 + } 274 + }() 275 + 276 + go func() { 277 + defer wg.Done() 278 + links, e := c.GetHighlightsForURL(ctx, targetURL) 279 + mu.Lock() 280 + defer mu.Unlock() 281 + if e != nil { 282 + errs = append(errs, e) 283 + } else { 284 + highlights = links 285 + } 286 + }() 287 + 288 + go func() { 289 + defer wg.Done() 290 + links, e := c.GetBookmarksForURL(ctx, targetURL) 291 + mu.Lock() 292 + defer mu.Unlock() 293 + if e != nil { 294 + errs = append(errs, e) 295 + } else { 296 + bookmarks = links 297 + } 298 + }() 299 + 300 + wg.Wait() 301 + 302 + if len(errs) > 0 { 303 + return annotations, highlights, bookmarks, errs[0] 304 + } 305 + 306 + return annotations, highlights, bookmarks, nil 307 + } 308 + 309 + func (c *Client) GetLikers(ctx context.Context, subjectURI string) ([]string, error) { 310 + params := url.Values{} 311 + params.Set("target", subjectURI) 312 + params.Set("collection", "at.margin.like") 313 + params.Set("path", ".subject.uri") 314 + 315 + endpoint := fmt.Sprintf("%s/links/distinct-dids?%s", c.baseURL, params.Encode()) 316 + 317 + req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) 318 + if err != nil { 319 + return nil, fmt.Errorf("failed to create request: %w", err) 320 + } 321 + req.Header.Set("User-Agent", UserAgent) 322 + 323 + resp, err := c.httpClient.Do(req) 324 + if err != nil { 325 + return nil, fmt.Errorf("request failed: %w", err) 326 + } 327 + defer resp.Body.Close() 328 + 329 + if resp.StatusCode != http.StatusOK { 330 + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) 331 + } 332 + 333 + var result struct { 334 + DIDs []string `json:"dids"` 335 + } 336 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 337 + return nil, fmt.Errorf("failed to decode response: %w", err) 338 + } 339 + 340 + return result.DIDs, nil 341 + }
+228
backend/internal/crypto/cid.go
··· 1 + package crypto 2 + 3 + import ( 4 + "bytes" 5 + "encoding/json" 6 + "fmt" 7 + "sort" 8 + "strings" 9 + 10 + "github.com/fxamacker/cbor/v2" 11 + "github.com/ipfs/go-cid" 12 + "github.com/multiformats/go-multihash" 13 + ) 14 + 15 + const ( 16 + DagCBORCodec = 0x71 17 + SHA256Code = multihash.SHA2_256 18 + ) 19 + 20 + type CIDVerificationError struct { 21 + ExpectedCID string 22 + ComputedCID string 23 + RecordURI string 24 + } 25 + 26 + func (e *CIDVerificationError) Error() string { 27 + return fmt.Sprintf("CID verification failed for %s: expected %s, computed %s", 28 + e.RecordURI, e.ExpectedCID, e.ComputedCID) 29 + } 30 + 31 + func VerifyRecordCID(recordJSON json.RawMessage, expectedCID string, recordURI string) error { 32 + if expectedCID == "" { 33 + return nil 34 + } 35 + 36 + expectedC, err := cid.Decode(expectedCID) 37 + if err != nil { 38 + return fmt.Errorf("invalid CID format: %w", err) 39 + } 40 + 41 + cborBytes, err := jsonToDAGCBOR(recordJSON) 42 + if err != nil { 43 + return fmt.Errorf("failed to encode as DAG-CBOR: %w", err) 44 + } 45 + 46 + mh, err := multihash.Sum(cborBytes, SHA256Code, -1) 47 + if err != nil { 48 + return fmt.Errorf("failed to compute hash: %w", err) 49 + } 50 + 51 + computedC := cid.NewCidV1(DagCBORCodec, mh) 52 + 53 + if !expectedC.Equals(computedC) { 54 + return &CIDVerificationError{ 55 + ExpectedCID: expectedCID, 56 + ComputedCID: computedC.String(), 57 + RecordURI: recordURI, 58 + } 59 + } 60 + 61 + return nil 62 + } 63 + 64 + func jsonToDAGCBOR(jsonData json.RawMessage) ([]byte, error) { 65 + var data interface{} 66 + if err := json.Unmarshal(jsonData, &data); err != nil { 67 + return nil, err 68 + } 69 + 70 + processed := processValue(data) 71 + 72 + encMode, err := cbor.CanonicalEncOptions().EncMode() 73 + if err != nil { 74 + return nil, err 75 + } 76 + 77 + return encMode.Marshal(processed) 78 + } 79 + 80 + func processValue(v interface{}) interface{} { 81 + switch val := v.(type) { 82 + case map[string]interface{}: 83 + return processMap(val) 84 + case []interface{}: 85 + result := make([]interface{}, len(val)) 86 + for i, item := range val { 87 + result[i] = processValue(item) 88 + } 89 + return result 90 + case float64: 91 + if val == float64(int64(val)) { 92 + return int64(val) 93 + } 94 + return val 95 + case string: 96 + return val 97 + default: 98 + return val 99 + } 100 + } 101 + 102 + func processMap(m map[string]interface{}) interface{} { 103 + if link, ok := m["$link"].(string); ok && len(m) == 1 { 104 + c, err := cid.Decode(link) 105 + if err == nil { 106 + return cbor.Tag{ 107 + Number: 42, 108 + Content: append([]byte{0x00}, c.Bytes()...), 109 + } 110 + } 111 + } 112 + 113 + if bytesStr, ok := m["$bytes"].(string); ok && len(m) == 1 { 114 + bytesStr = strings.TrimRight(bytesStr, "=") 115 + decoded := decodeBase64(bytesStr) 116 + if decoded != nil { 117 + return decoded 118 + } 119 + } 120 + 121 + keys := make([]string, 0, len(m)) 122 + for k := range m { 123 + keys = append(keys, k) 124 + } 125 + sort.Strings(keys) 126 + 127 + result := make(map[string]interface{}, len(m)) 128 + for _, k := range keys { 129 + result[k] = processValue(m[k]) 130 + } 131 + 132 + return result 133 + } 134 + 135 + func decodeBase64(s string) []byte { 136 + switch len(s) % 4 { 137 + case 2: 138 + s += "==" 139 + case 3: 140 + s += "=" 141 + } 142 + 143 + decoded := make([]byte, len(s)) 144 + n := 0 145 + for i := 0; i < len(s); i += 4 { 146 + if i+4 > len(s) { 147 + break 148 + } 149 + chunk := s[i : i+4] 150 + val := uint32(0) 151 + for _, c := range chunk { 152 + var v byte 153 + switch { 154 + case c >= 'A' && c <= 'Z': 155 + v = byte(c - 'A') 156 + case c >= 'a' && c <= 'z': 157 + v = byte(c - 'a' + 26) 158 + case c >= '0' && c <= '9': 159 + v = byte(c - '0' + 52) 160 + case c == '+' || c == '-': 161 + v = 62 162 + case c == '/' || c == '_': 163 + v = 63 164 + case c == '=': 165 + v = 0 166 + default: 167 + return nil 168 + } 169 + val = val<<6 | uint32(v) 170 + } 171 + decoded[n] = byte(val >> 16) 172 + n++ 173 + if chunk[2] != '=' { 174 + decoded[n] = byte(val >> 8) 175 + n++ 176 + } 177 + if chunk[3] != '=' { 178 + decoded[n] = byte(val) 179 + n++ 180 + } 181 + } 182 + return decoded[:n] 183 + } 184 + 185 + func VerifyRecordCIDBatch(records []struct { 186 + JSON json.RawMessage 187 + CID string 188 + URI string 189 + }) []error { 190 + var errors []error 191 + for _, r := range records { 192 + if err := VerifyRecordCID(r.JSON, r.CID, r.URI); err != nil { 193 + errors = append(errors, err) 194 + } 195 + } 196 + return errors 197 + } 198 + 199 + func MustVerifyRecordCID(recordJSON json.RawMessage, expectedCID string, recordURI string) bool { 200 + return VerifyRecordCID(recordJSON, expectedCID, recordURI) == nil 201 + } 202 + 203 + func ComputeRecordCID(recordJSON json.RawMessage) (string, error) { 204 + cborBytes, err := jsonToDAGCBOR(recordJSON) 205 + if err != nil { 206 + return "", fmt.Errorf("failed to encode as DAG-CBOR: %w", err) 207 + } 208 + 209 + mh, err := multihash.Sum(cborBytes, SHA256Code, -1) 210 + if err != nil { 211 + return "", fmt.Errorf("failed to compute hash: %w", err) 212 + } 213 + 214 + c := cid.NewCidV1(DagCBORCodec, mh) 215 + return c.String(), nil 216 + } 217 + 218 + func CompareRecordBytes(a, b json.RawMessage) (bool, error) { 219 + cborA, err := jsonToDAGCBOR(a) 220 + if err != nil { 221 + return false, err 222 + } 223 + cborB, err := jsonToDAGCBOR(b) 224 + if err != nil { 225 + return false, err 226 + } 227 + return bytes.Equal(cborA, cborB), nil 228 + }
+40 -7
backend/internal/firehose/ingester.go
··· 10 10 "time" 11 11 12 12 "github.com/gorilla/websocket" 13 + "margin.at/internal/crypto" 13 14 "margin.at/internal/db" 14 15 internal_sync "margin.at/internal/sync" 15 16 "margin.at/internal/xrpc" 16 17 ) 18 + 19 + var CIDVerificationEnabled = true 17 20 18 21 const ( 19 22 CollectionAnnotation = "at.margin.annotation" ··· 28 31 CollectionSembleCollection = "network.cosmik.collection" 29 32 ) 30 33 31 - var RelayURL = "wss://jetstream2.us-east.bsky.network/subscribe" 34 + var RelayURLs = []string{ 35 + "wss://jetstream2.us-east.bsky.network/subscribe", 36 + "wss://jetstream2.fr.hose.cam/subscribe", 37 + "wss://jetstream.fire.hose.cam/subscribe", 38 + } 39 + 40 + var RelayURL = RelayURLs[0] 32 41 33 42 type Ingester struct { 34 - db *db.DB 35 - sync *internal_sync.Service 36 - cancel context.CancelFunc 37 - handlers map[string]RecordHandler 43 + db *db.DB 44 + sync *internal_sync.Service 45 + cancel context.CancelFunc 46 + handlers map[string]RecordHandler 47 + currentRelayIdx int 38 48 } 39 49 40 50 type RecordHandler func(event *FirehoseEvent) ··· 80 90 } 81 91 82 92 func (i *Ingester) run(ctx context.Context) { 93 + consecutiveFailures := 0 94 + maxFailuresBeforeSwitch := 3 95 + 83 96 for { 84 97 select { 85 98 case <-ctx.Done(): 86 99 return 87 100 default: 88 101 if err := i.subscribe(ctx); err != nil { 89 - log.Printf("Jetstream error: %v, reconnecting in 5s...", err) 102 + consecutiveFailures++ 103 + log.Printf("Jetstream error (relay %d): %v, reconnecting in 5s...", i.currentRelayIdx, err) 104 + 105 + if consecutiveFailures >= maxFailuresBeforeSwitch { 106 + i.currentRelayIdx = (i.currentRelayIdx + 1) % len(RelayURLs) 107 + log.Printf("Switching to relay %d: %s", i.currentRelayIdx, RelayURLs[i.currentRelayIdx]) 108 + consecutiveFailures = 0 109 + } 110 + 90 111 if ctx.Err() != nil { 91 112 return 92 113 } 93 114 time.Sleep(5 * time.Second) 115 + } else { 116 + consecutiveFailures = 0 94 117 } 95 118 } 96 119 } ··· 120 143 collections = append(collections, collection) 121 144 } 122 145 123 - url := fmt.Sprintf("%s?wantedCollections=%s", RelayURL, strings.Join(collections, "&wantedCollections=")) 146 + relayURL := RelayURLs[i.currentRelayIdx] 147 + url := fmt.Sprintf("%s?wantedCollections=%s", relayURL, strings.Join(collections, "&wantedCollections=")) 124 148 if cursor > 0 { 125 149 url = fmt.Sprintf("%s&cursor=%d", url, cursor) 126 150 } ··· 171 195 switch commit.Operation { 172 196 case "create", "update": 173 197 if len(commit.Record) > 0 { 198 + if CIDVerificationEnabled && commit.Cid != "" { 199 + if err := crypto.VerifyRecordCID(commit.Record, commit.Cid, uri); err != nil { 200 + log.Printf("CID verification failed for %s: %v (skipping)", uri, err) 201 + return 202 + } 203 + } 204 + 174 205 firehoseEvent := &FirehoseEvent{ 175 206 Repo: event.Did, 176 207 Collection: commit.Collection, ··· 178 209 Record: commit.Record, 179 210 Operation: commit.Operation, 180 211 Cursor: event.Time, 212 + CID: commit.Cid, 181 213 } 182 214 183 215 i.dispatchToHandler(firehoseEvent) ··· 267 299 Record json.RawMessage `json:"record"` 268 300 Operation string `json:"operation"` 269 301 Cursor int64 `json:"cursor"` 302 + CID string `json:"cid"` 270 303 } 271 304 272 305 func (i *Ingester) handleAnnotation(event *FirehoseEvent) {
+19
backend/internal/oauth/client.go
··· 208 208 return &meta, nil 209 209 } 210 210 211 + func (c *Client) GetAuthServerMetadataForSignup(ctx context.Context, url string) (*AuthServerMetadata, error) { 212 + url = strings.TrimSuffix(url, "/") 213 + 214 + metaURL := fmt.Sprintf("%s/.well-known/oauth-authorization-server", url) 215 + metaResp, err := http.Get(metaURL) 216 + if err == nil && metaResp.StatusCode == 200 { 217 + defer metaResp.Body.Close() 218 + var meta AuthServerMetadata 219 + if err := json.NewDecoder(metaResp.Body).Decode(&meta); err == nil && meta.Issuer != "" { 220 + return &meta, nil 221 + } 222 + } 223 + if metaResp != nil { 224 + metaResp.Body.Close() 225 + } 226 + 227 + return c.GetAuthServerMetadata(ctx, url) 228 + } 229 + 211 230 func (c *Client) GeneratePKCE() (verifier, challenge string) { 212 231 b := make([]byte, 32) 213 232 rand.Read(b)
+88 -1
backend/internal/oauth/handler.go
··· 283 283 }) 284 284 } 285 285 286 + func (h *Handler) HandleSignup(w http.ResponseWriter, r *http.Request) { 287 + if r.Method != "POST" { 288 + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 289 + return 290 + } 291 + 292 + var req struct { 293 + PdsURL string `json:"pds_url"` 294 + } 295 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 296 + http.Error(w, "Invalid request body", http.StatusBadRequest) 297 + return 298 + } 299 + 300 + if req.PdsURL == "" { 301 + http.Error(w, "PDS URL is required", http.StatusBadRequest) 302 + return 303 + } 304 + 305 + client := h.getDynamicClient(r) 306 + ctx := r.Context() 307 + 308 + meta, err := client.GetAuthServerMetadataForSignup(ctx, req.PdsURL) 309 + if err != nil { 310 + log.Printf("Failed to get auth metadata for signup from %s: %v", req.PdsURL, err) 311 + w.Header().Set("Content-Type", "application/json") 312 + w.WriteHeader(http.StatusBadRequest) 313 + json.NewEncoder(w).Encode(map[string]string{"error": "Failed to connect to PDS"}) 314 + return 315 + } 316 + 317 + dpopKey, err := client.GenerateDPoPKey() 318 + if err != nil { 319 + w.Header().Set("Content-Type", "application/json") 320 + w.WriteHeader(http.StatusInternalServerError) 321 + json.NewEncoder(w).Encode(map[string]string{"error": "Internal error"}) 322 + return 323 + } 324 + 325 + pkceVerifier, pkceChallenge := client.GeneratePKCE() 326 + scope := "atproto offline_access blob:* include:at.margin.authFull" 327 + 328 + parResp, state, dpopNonce, err := client.SendPAR(meta, "", scope, dpopKey, pkceChallenge) 329 + if err != nil { 330 + log.Printf("PAR request failed for signup: %v", err) 331 + w.Header().Set("Content-Type", "application/json") 332 + w.WriteHeader(http.StatusInternalServerError) 333 + json.NewEncoder(w).Encode(map[string]string{"error": "Failed to initiate signup"}) 334 + return 335 + } 336 + 337 + pending := &PendingAuth{ 338 + State: state, 339 + DID: "", 340 + Handle: "", 341 + PDS: req.PdsURL, 342 + AuthServer: meta.TokenEndpoint, 343 + Issuer: meta.Issuer, 344 + PKCEVerifier: pkceVerifier, 345 + DPoPKey: dpopKey, 346 + DPoPNonce: dpopNonce, 347 + CreatedAt: time.Now(), 348 + } 349 + 350 + h.pendingMu.Lock() 351 + h.pending[state] = pending 352 + h.pendingMu.Unlock() 353 + 354 + authURL, _ := url.Parse(meta.AuthorizationEndpoint) 355 + q := authURL.Query() 356 + q.Set("client_id", client.ClientID) 357 + q.Set("request_uri", parResp.RequestURI) 358 + authURL.RawQuery = q.Encode() 359 + 360 + w.Header().Set("Content-Type", "application/json") 361 + json.NewEncoder(w).Encode(map[string]string{ 362 + "authorizationUrl": authURL.String(), 363 + }) 364 + } 365 + 286 366 func (h *Handler) HandleCallback(w http.ResponseWriter, r *http.Request) { 287 367 client := h.getDynamicClient(r) 288 368 ··· 318 398 } 319 399 320 400 ctx := r.Context() 321 - meta, err := client.GetAuthServerMetadata(ctx, pending.PDS) 401 + meta, err := client.GetAuthServerMetadataForSignup(ctx, pending.PDS) 322 402 if err != nil { 403 + log.Printf("Failed to get auth metadata in callback for %s: %v", pending.PDS, err) 323 404 http.Error(w, fmt.Sprintf("Failed to get auth metadata: %v", err), http.StatusInternalServerError) 324 405 return 325 406 } ··· 327 408 tokenResp, newNonce, err := client.ExchangeCode(meta, code, pending.PKCEVerifier, pending.DPoPKey, pending.DPoPNonce) 328 409 if err != nil { 329 410 http.Error(w, fmt.Sprintf("Token exchange failed: %v", err), http.StatusInternalServerError) 411 + return 412 + } 413 + 414 + if pending.DID != "" && tokenResp.Sub != pending.DID { 415 + log.Printf("Security: OAuth sub mismatch, expected %s, got %s", pending.DID, tokenResp.Sub) 416 + http.Error(w, "Account identity mismatch, authorization returned different account", http.StatusBadRequest) 330 417 return 331 418 } 332 419
+179
backend/internal/slingshot/client.go
··· 1 + package slingshot 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "net/http" 8 + "net/url" 9 + "time" 10 + ) 11 + 12 + const ( 13 + DefaultBaseURL = "https://slingshot.microcosm.blue" 14 + DefaultTimeout = 5 * time.Second 15 + UserAgent = "Margin (margin.at)" 16 + ) 17 + 18 + type Client struct { 19 + baseURL string 20 + httpClient *http.Client 21 + } 22 + 23 + func NewClient() *Client { 24 + return &Client{ 25 + baseURL: DefaultBaseURL, 26 + httpClient: &http.Client{ 27 + Timeout: DefaultTimeout, 28 + }, 29 + } 30 + } 31 + 32 + func NewClientWithURL(baseURL string) *Client { 33 + return &Client{ 34 + baseURL: baseURL, 35 + httpClient: &http.Client{ 36 + Timeout: DefaultTimeout, 37 + }, 38 + } 39 + } 40 + 41 + type Identity struct { 42 + DID string `json:"did"` 43 + Handle string `json:"handle"` 44 + PDS string `json:"pds"` 45 + } 46 + 47 + type Record struct { 48 + URI string `json:"uri"` 49 + CID string `json:"cid"` 50 + Value json.RawMessage `json:"value"` 51 + } 52 + 53 + func (c *Client) ResolveIdentity(ctx context.Context, identifier string) (*Identity, error) { 54 + endpoint := fmt.Sprintf("%s/identity/%s", c.baseURL, url.PathEscape(identifier)) 55 + 56 + req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) 57 + if err != nil { 58 + return nil, fmt.Errorf("failed to create request: %w", err) 59 + } 60 + req.Header.Set("User-Agent", UserAgent) 61 + 62 + resp, err := c.httpClient.Do(req) 63 + if err != nil { 64 + return nil, fmt.Errorf("request failed: %w", err) 65 + } 66 + defer resp.Body.Close() 67 + 68 + if resp.StatusCode == http.StatusNotFound { 69 + return nil, fmt.Errorf("identity not found: %s", identifier) 70 + } 71 + 72 + if resp.StatusCode != http.StatusOK { 73 + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) 74 + } 75 + 76 + var identity Identity 77 + if err := json.NewDecoder(resp.Body).Decode(&identity); err != nil { 78 + return nil, fmt.Errorf("failed to decode response: %w", err) 79 + } 80 + 81 + return &identity, nil 82 + } 83 + 84 + func (c *Client) GetRecord(ctx context.Context, uri string) (*Record, error) { 85 + params := url.Values{} 86 + params.Set("uri", uri) 87 + 88 + endpoint := fmt.Sprintf("%s/record?%s", c.baseURL, params.Encode()) 89 + 90 + req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) 91 + if err != nil { 92 + return nil, fmt.Errorf("failed to create request: %w", err) 93 + } 94 + req.Header.Set("User-Agent", UserAgent) 95 + 96 + resp, err := c.httpClient.Do(req) 97 + if err != nil { 98 + return nil, fmt.Errorf("request failed: %w", err) 99 + } 100 + defer resp.Body.Close() 101 + 102 + if resp.StatusCode == http.StatusNotFound { 103 + return nil, fmt.Errorf("record not found: %s", uri) 104 + } 105 + 106 + if resp.StatusCode != http.StatusOK { 107 + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) 108 + } 109 + 110 + var record Record 111 + if err := json.NewDecoder(resp.Body).Decode(&record); err != nil { 112 + return nil, fmt.Errorf("failed to decode response: %w", err) 113 + } 114 + 115 + return &record, nil 116 + } 117 + 118 + func (c *Client) GetRecordByParts(ctx context.Context, repo, collection, rkey string) (*Record, error) { 119 + uri := fmt.Sprintf("at://%s/%s/%s", repo, collection, rkey) 120 + return c.GetRecord(ctx, uri) 121 + } 122 + 123 + type ListRecordsResponse struct { 124 + Records []Record `json:"records"` 125 + Cursor string `json:"cursor,omitempty"` 126 + } 127 + 128 + func (c *Client) ListRecords(ctx context.Context, repo, collection string, limit int, cursor string) (*ListRecordsResponse, error) { 129 + params := url.Values{} 130 + params.Set("repo", repo) 131 + params.Set("collection", collection) 132 + if limit > 0 { 133 + params.Set("limit", fmt.Sprintf("%d", limit)) 134 + } 135 + if cursor != "" { 136 + params.Set("cursor", cursor) 137 + } 138 + 139 + endpoint := fmt.Sprintf("%s/records?%s", c.baseURL, params.Encode()) 140 + 141 + req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) 142 + if err != nil { 143 + return nil, fmt.Errorf("failed to create request: %w", err) 144 + } 145 + req.Header.Set("User-Agent", UserAgent) 146 + 147 + resp, err := c.httpClient.Do(req) 148 + if err != nil { 149 + return nil, fmt.Errorf("request failed: %w", err) 150 + } 151 + defer resp.Body.Close() 152 + 153 + if resp.StatusCode != http.StatusOK { 154 + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) 155 + } 156 + 157 + var listResp ListRecordsResponse 158 + if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil { 159 + return nil, fmt.Errorf("failed to decode response: %w", err) 160 + } 161 + 162 + return &listResp, nil 163 + } 164 + 165 + func (c *Client) ResolveDID(ctx context.Context, did string) (string, error) { 166 + identity, err := c.ResolveIdentity(ctx, did) 167 + if err != nil { 168 + return "", err 169 + } 170 + return identity.PDS, nil 171 + } 172 + 173 + func (c *Client) ResolveHandle(ctx context.Context, handle string) (string, error) { 174 + identity, err := c.ResolveIdentity(ctx, handle) 175 + if err != nil { 176 + return "", err 177 + } 178 + return identity.DID, nil 179 + }
+11
backend/internal/sync/service.go
··· 5 5 "encoding/json" 6 6 "fmt" 7 7 "io" 8 + "log" 8 9 "net/http" 9 10 "strings" 10 11 "time" 11 12 13 + "margin.at/internal/crypto" 12 14 "margin.at/internal/db" 13 15 "margin.at/internal/xrpc" 14 16 ) 17 + 18 + var CIDVerificationEnabled = true 15 19 16 20 type Service struct { 17 21 db *db.DB ··· 82 86 } 83 87 84 88 for _, rec := range output.Records { 89 + if CIDVerificationEnabled && rec.CID != "" { 90 + if err := crypto.VerifyRecordCID(rec.Value, rec.CID, rec.URI); err != nil { 91 + log.Printf("CID verification failed for %s: %v (skipping)", rec.URI, err) 92 + continue 93 + } 94 + } 95 + 85 96 err := s.upsertRecord(did, collectionNSID, rec.URI, rec.CID, rec.Value) 86 97 if err != nil { 87 98 fmt.Printf("Error upserting %s: %v\n", rec.URI, err)
+6 -8
backend/internal/xrpc/client.go
··· 11 11 "fmt" 12 12 "io" 13 13 "net/http" 14 - "strings" 15 14 "time" 16 15 17 16 "github.com/go-jose/go-jose/v4" ··· 193 192 } 194 193 195 194 func (c *Client) DeleteRecordByURI(ctx context.Context, uri string) error { 196 - 197 - if !strings.HasPrefix(uri, "at://") { 198 - return fmt.Errorf("invalid AT URI format") 195 + parsed, err := ParseATURI(uri) 196 + if err != nil { 197 + return err 199 198 } 200 199 201 - parts := strings.Split(strings.TrimPrefix(uri, "at://"), "/") 202 - if len(parts) != 3 { 203 - return fmt.Errorf("invalid AT URI format") 200 + if parsed.Collection == "" || parsed.RKey == "" { 201 + return fmt.Errorf("invalid AT-URI: must include collection and rkey") 204 202 } 205 203 206 - return c.DeleteRecord(ctx, parts[0], parts[1], parts[2]) 204 + return c.DeleteRecord(ctx, parsed.DID, parsed.Collection, parsed.RKey) 207 205 } 208 206 209 207 type PutRecordInput struct {
+107
backend/internal/xrpc/utils.go
··· 1 1 package xrpc 2 2 3 3 import ( 4 + "context" 4 5 "encoding/json" 5 6 "fmt" 7 + "log" 6 8 "net/http" 9 + "regexp" 7 10 "strings" 8 11 "time" 12 + 13 + "margin.at/internal/slingshot" 9 14 ) 10 15 16 + var SlingshotClient = slingshot.NewClient() 17 + 18 + var ( 19 + didPattern = regexp.MustCompile(`^did:[a-z]+:[a-zA-Z0-9._:%-]+$`) 20 + nsidPattern = regexp.MustCompile(`^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)+$`) 21 + rkeyPattern = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`) 22 + ) 23 + 24 + type ATURI struct { 25 + DID string 26 + Collection string 27 + RKey string 28 + } 29 + 30 + func ParseATURI(uri string) (*ATURI, error) { 31 + if !strings.HasPrefix(uri, "at://") { 32 + return nil, fmt.Errorf("invalid AT-URI: must start with at://") 33 + } 34 + 35 + path := strings.TrimPrefix(uri, "at://") 36 + parts := strings.Split(path, "/") 37 + 38 + if len(parts) < 1 || parts[0] == "" { 39 + return nil, fmt.Errorf("invalid AT-URI: missing DID authority") 40 + } 41 + 42 + did := parts[0] 43 + if !didPattern.MatchString(did) { 44 + return nil, fmt.Errorf("invalid AT-URI: malformed DID %q", did) 45 + } 46 + 47 + result := &ATURI{DID: did} 48 + 49 + if len(parts) >= 2 && parts[1] != "" { 50 + collection := parts[1] 51 + if !nsidPattern.MatchString(collection) { 52 + return nil, fmt.Errorf("invalid AT-URI: malformed collection NSID %q", collection) 53 + } 54 + result.Collection = collection 55 + } 56 + 57 + if len(parts) >= 3 && parts[2] != "" { 58 + rkey := parts[2] 59 + if !rkeyPattern.MatchString(rkey) || strings.HasPrefix(rkey, ".") || strings.HasSuffix(rkey, ".") { 60 + return nil, fmt.Errorf("invalid AT-URI: malformed record key %q", rkey) 61 + } 62 + if len(rkey) > 512 { 63 + return nil, fmt.Errorf("invalid AT-URI: record key too long (max 512)") 64 + } 65 + result.RKey = rkey 66 + } 67 + 68 + if len(parts) > 3 { 69 + return nil, fmt.Errorf("invalid AT-URI: too many path segments") 70 + } 71 + 72 + return result, nil 73 + } 74 + 75 + func (a *ATURI) String() string { 76 + if a.Collection == "" { 77 + return fmt.Sprintf("at://%s", a.DID) 78 + } 79 + if a.RKey == "" { 80 + return fmt.Sprintf("at://%s/%s", a.DID, a.Collection) 81 + } 82 + return fmt.Sprintf("at://%s/%s/%s", a.DID, a.Collection, a.RKey) 83 + } 84 + 85 + func init() { 86 + log.Printf("Slingshot client initialized: %s", slingshot.DefaultBaseURL) 87 + } 88 + 11 89 func ResolveDIDToPDS(did string) (string, error) { 90 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 91 + defer cancel() 92 + 93 + if pds, err := SlingshotClient.ResolveDID(ctx, did); err == nil && pds != "" { 94 + return pds, nil 95 + } 96 + 97 + return resolveDIDToPDSDirect(did) 98 + } 99 + 100 + func resolveDIDToPDSDirect(did string) (string, error) { 12 101 var docURL string 13 102 if strings.HasPrefix(did, "did:plc:") { 14 103 docURL = fmt.Sprintf("https://plc.directory/%s", did) ··· 34 123 35 124 var doc struct { 36 125 Service []struct { 126 + ID string `json:"id"` 37 127 Type string `json:"type"` 38 128 ServiceEndpoint string `json:"serviceEndpoint"` 39 129 } `json:"service"` ··· 43 133 } 44 134 45 135 for _, svc := range doc.Service { 136 + if svc.ID == "#atproto_pds" && svc.Type == "AtprotoPersonalDataServer" { 137 + return svc.ServiceEndpoint, nil 138 + } 139 + } 140 + for _, svc := range doc.Service { 46 141 if svc.Type == "AtprotoPersonalDataServer" { 47 142 return svc.ServiceEndpoint, nil 48 143 } 49 144 } 50 145 return "", nil 51 146 } 147 + 52 148 func ResolveHandle(handle string) (string, error) { 53 149 if strings.HasPrefix(handle, "did:") { 54 150 return handle, nil 55 151 } 56 152 153 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 154 + defer cancel() 155 + 156 + if did, err := SlingshotClient.ResolveHandle(ctx, handle); err == nil && did != "" { 157 + return did, nil 158 + } 159 + 160 + return resolveHandleDirect(handle) 161 + } 162 + 163 + func resolveHandleDirect(handle string) (string, error) { 57 164 url := fmt.Sprintf("https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=%s", handle) 58 165 client := &http.Client{ 59 166 Timeout: 5 * time.Second,
+1 -1
extension/background/service-worker.js
··· 398 398 sendResponse({ success: true, data: allItems }); 399 399 400 400 if (sender.tab) { 401 - const count = items.length; 401 + const count = allItems.length; 402 402 chrome.action 403 403 .setBadgeText({ 404 404 text: count > 0 ? count.toString() : "",
+3 -3
extension/content/content.css
··· 1 1 ::highlight(margin-highlight-preview) { 2 - background-color: rgba(168, 85, 247, 0.3); 2 + background-color: rgba(149, 122, 134, 0.3); 3 3 color: inherit; 4 4 } 5 5 6 6 ::highlight(margin-scroll-highlight) { 7 - background-color: rgba(99, 102, 241, 0.4); 7 + background-color: rgba(149, 122, 134, 0.5); 8 8 color: inherit; 9 9 } 10 10 11 11 ::highlight(margin-page-highlights) { 12 - background-color: rgba(252, 211, 77, 0.3); 12 + background-color: rgba(149, 122, 134, 0.25); 13 13 color: inherit; 14 14 } 15 15
+149 -110
extension/content/content.js
··· 9 9 const OVERLAY_STYLES = ` 10 10 :host { 11 11 all: initial; 12 - --bg-primary: #09090b; 13 - --bg-secondary: #0f0f12; 14 - --bg-tertiary: #18181b; 15 - --bg-card: #09090b; 16 - --bg-elevated: #18181b; 17 - --bg-hover: #27272a; 12 + --bg-primary: #0a0a0d; 13 + --bg-secondary: #121216; 14 + --bg-tertiary: #1a1a1f; 15 + --bg-card: #0f0f13; 16 + --bg-elevated: #18181d; 17 + --bg-hover: #1e1e24; 18 18 19 - --text-primary: #e4e4e7; 20 - --text-secondary: #a1a1aa; 21 - --border: #27272a; 19 + --text-primary: #eaeaee; 20 + --text-secondary: #b7b6c5; 21 + --text-tertiary: #6e6d7a; 22 + --border: rgba(183, 182, 197, 0.12); 22 23 23 - --accent: #6366f1; 24 - --accent-hover: #4f46e5; 24 + --accent: #957a86; 25 + --accent-hover: #a98d98; 26 + --accent-subtle: rgba(149, 122, 134, 0.15); 25 27 } 26 28 27 29 :host(.light) { 28 - --bg-primary: #ffffff; 29 - --bg-secondary: #f4f4f5; 30 - --bg-tertiary: #e4e4e7; 30 + --bg-primary: #f8f8fa; 31 + --bg-secondary: #ffffff; 32 + --bg-tertiary: #f0f0f4; 31 33 --bg-card: #ffffff; 32 - --bg-elevated: #f4f4f5; 33 - --bg-hover: #e4e4e7; 34 + --bg-elevated: #ffffff; 35 + --bg-hover: #eeeef2; 34 36 35 - --text-primary: #18181b; 36 - --text-secondary: #52525b; 37 - --border: #e4e4e7; 37 + --text-primary: #18171c; 38 + --text-secondary: #5c495a; 39 + --text-tertiary: #8a8494; 40 + --border: rgba(92, 73, 90, 0.12); 38 41 39 - --accent: #4f46e5; 40 - --accent-hover: #4338ca; 42 + --accent: #7a5f6d; 43 + --accent-hover: #664e5b; 44 + --accent-subtle: rgba(149, 122, 134, 0.12); 41 45 } 42 46 43 47 .margin-overlay { ··· 51 55 52 56 .margin-popover { 53 57 position: absolute; 54 - width: 320px; 58 + width: 300px; 55 59 background: var(--bg-card); 56 60 border: 1px solid var(--border); 57 61 border-radius: 12px; 58 62 padding: 0; 59 - box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5), 0 10px 10px -5px rgba(0, 0, 0, 0.2); 63 + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); 60 64 display: flex; 61 65 flex-direction: column; 62 66 pointer-events: auto; 63 67 z-index: 2147483647; 64 - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 68 + font-family: "IBM Plex Sans", -apple-system, BlinkMacSystemFont, sans-serif; 65 69 color: var(--text-primary); 66 70 opacity: 0; 67 - transform: scale(0.95); 71 + transform: translateY(-4px); 68 72 animation: popover-in 0.15s forwards; 69 - max-height: 480px; 73 + max-height: 400px; 70 74 overflow: hidden; 71 75 } 72 - @keyframes popover-in { to { opacity: 1; transform: scale(1); } } 76 + @keyframes popover-in { to { opacity: 1; transform: translateY(0); } } 77 + 73 78 .popover-header { 74 - padding: 12px 16px; 79 + padding: 10px 14px; 75 80 border-bottom: 1px solid var(--border); 76 81 display: flex; 77 82 justify-content: space-between; 78 83 align-items: center; 79 - background: var(--bg-secondary); 84 + background: var(--bg-primary); 80 85 border-radius: 12px 12px 0 0; 81 - font-weight: 600; 82 - font-size: 13px; 83 - color: var(--text-primary); 86 + font-weight: 500; 87 + font-size: 11px; 88 + color: var(--text-tertiary); 89 + text-transform: uppercase; 90 + letter-spacing: 0.5px; 91 + } 92 + .popover-close { 93 + background: none; 94 + border: none; 95 + color: var(--text-tertiary); 96 + cursor: pointer; 97 + padding: 2px; 98 + font-size: 16px; 99 + line-height: 1; 100 + opacity: 0.6; 101 + transition: opacity 0.15s; 84 102 } 103 + .popover-close:hover { opacity: 1; } 104 + 85 105 .popover-scroll-area { 86 106 overflow-y: auto; 87 - max-height: 400px; 107 + max-height: 340px; 88 108 } 89 - .popover-item-block { 109 + 110 + .comment-item { 111 + padding: 12px 14px; 90 112 border-bottom: 1px solid var(--border); 91 - margin-bottom: 0; 92 - animation: fade-in 0.2s; 93 113 } 94 - .popover-item-block:last-child { 114 + .comment-item:last-child { 95 115 border-bottom: none; 96 116 } 97 - .popover-item-header { 98 - padding: 12px 16px 4px; 117 + 118 + .comment-header { 99 119 display: flex; 100 120 align-items: center; 101 121 gap: 8px; 122 + margin-bottom: 6px; 102 123 } 103 - .popover-avatar { 104 - width: 24px; height: 24px; border-radius: 50%; background: var(--bg-hover); 105 - display: flex; align-items: center; justify-content: center; 106 - font-size: 10px; color: var(--text-secondary); 124 + .comment-avatar { 125 + width: 22px; 126 + height: 22px; 127 + border-radius: 50%; 128 + background: var(--accent); 129 + display: flex; 130 + align-items: center; 131 + justify-content: center; 132 + font-size: 9px; 133 + font-weight: 600; 134 + color: white; 107 135 } 108 - .popover-handle { font-size: 12px; font-weight: 600; color: var(--text-primary); } 109 - .popover-close { background: none; border: none; color: var(--text-secondary); cursor: pointer; padding: 4px; } 110 - .popover-close:hover { color: var(--text-primary); } 111 - .popover-content { padding: 4px 16px 12px; font-size: 13px; line-height: 1.5; color: var(--text-primary); } 112 - .popover-quote { 113 - margin-top: 8px; padding: 6px 10px; background: var(--bg-tertiary); 114 - border-left: 2px solid var(--accent); border-radius: 4px; 115 - font-size: 11px; color: var(--text-secondary); font-style: italic; 136 + .comment-handle { 137 + font-size: 12px; 138 + font-weight: 600; 139 + color: var(--text-primary); 116 140 } 117 - .popover-actions { 118 - padding: 8px 16px; 119 - display: flex; justify-content: flex-end; gap: 8px; 141 + .comment-time { 142 + font-size: 11px; 143 + color: var(--text-tertiary); 144 + margin-left: auto; 120 145 } 121 - .btn-action { 122 - background: none; border: 1px solid var(--border); border-radius: 4px; 123 - padding: 4px 8px; color: var(--text-secondary); font-size: 11px; cursor: pointer; 146 + 147 + .comment-text { 148 + font-size: 13px; 149 + line-height: 1.5; 150 + color: var(--text-primary); 151 + margin-bottom: 8px; 124 152 } 125 - .btn-action:hover { background: var(--bg-hover); color: var(--text-primary); } 153 + 154 + .highlight-only-badge { 155 + display: inline-flex; 156 + align-items: center; 157 + gap: 4px; 158 + font-size: 11px; 159 + color: var(--text-tertiary); 160 + font-style: italic; 161 + } 162 + 163 + .comment-actions { 164 + display: flex; 165 + gap: 8px; 166 + margin-top: 8px; 167 + } 168 + .highlight-only-badge { 169 + font-size: 11px; 170 + color: var(--text-tertiary); 171 + font-style: italic; 172 + opacity: 0.7; 173 + } 174 + .comment-action-btn { 175 + background: none; 176 + border: none; 177 + padding: 4px 8px; 178 + color: var(--text-tertiary); 179 + font-size: 11px; 180 + cursor: pointer; 181 + border-radius: 4px; 182 + transition: all 0.15s; 183 + } 184 + .comment-action-btn:hover { 185 + background: var(--bg-hover); 186 + color: var(--text-secondary); 187 + } 126 188 127 189 .margin-selection-popup { 128 190 position: fixed; ··· 132 194 background: var(--bg-card); 133 195 border: 1px solid var(--border); 134 196 border-radius: 8px; 135 - box-shadow: 0 8px 16px rgba(0,0,0,0.4); 197 + box-shadow: 0 8px 24px rgba(0,0,0,0.3); 136 198 z-index: 2147483647; 137 199 pointer-events: auto; 138 - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 200 + font-family: "IBM Plex Sans", -apple-system, BlinkMacSystemFont, sans-serif; 139 201 animation: popover-in 0.15s forwards; 140 202 } 141 203 .selection-btn { ··· 168 230 border-radius: 12px; 169 231 padding: 16px; 170 232 box-sizing: border-box; 171 - box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5); 233 + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.4); 172 234 z-index: 2147483647; 173 235 pointer-events: auto; 174 - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 236 + font-family: "IBM Plex Sans", -apple-system, BlinkMacSystemFont, sans-serif; 175 237 color: var(--text-primary); 176 238 animation: popover-in 0.15s forwards; 177 239 overflow: hidden; ··· 181 243 } 182 244 .inline-compose-quote { 183 245 padding: 8px 12px; 184 - background: var(--bg-tertiary); 185 - border-left: 3px solid var(--accent); 246 + background: var(--accent-subtle); 247 + border-left: 2px solid var(--accent); 186 248 border-radius: 4px; 187 249 font-size: 12px; 188 250 color: var(--text-secondary); ··· 247 309 } 248 310 .reply-section { 249 311 border-top: 1px solid var(--border); 250 - padding: 12px 16px; 251 - background: var(--bg-secondary); 312 + padding: 10px 14px; 313 + background: var(--bg-primary); 252 314 border-radius: 0 0 12px 12px; 253 315 } 254 316 .reply-textarea { 255 317 width: 100%; 256 - min-height: 60px; 318 + min-height: 50px; 257 319 padding: 8px 10px; 258 320 background: var(--bg-elevated); 259 321 border: 1px solid var(--border); ··· 887 949 .join(","); 888 950 popoverEl.dataset.itemIds = ids; 889 951 890 - const popWidth = 320; 952 + const popWidth = 300; 891 953 const screenWidth = window.innerWidth; 892 954 let finalLeft = left; 893 955 if (left + popWidth > screenWidth) finalLeft = screenWidth - popWidth - 20; ··· 895 957 popoverEl.style.top = `${top + 20}px`; 896 958 popoverEl.style.left = `${finalLeft}px`; 897 959 898 - const hasHighlights = items.some((item) => item.type === "Highlight"); 899 - const hasAnnotations = items.some((item) => item.type !== "Highlight"); 900 - let title; 901 - if (items.length > 1) { 902 - if (hasHighlights && hasAnnotations) { 903 - title = `${items.length} Items`; 904 - } else if (hasHighlights) { 905 - title = `${items.length} Highlights`; 906 - } else { 907 - title = `${items.length} Annotations`; 908 - } 909 - } else { 910 - title = items[0]?.type === "Highlight" ? "Highlight" : "Annotation"; 911 - } 960 + const count = items.length; 961 + const title = count === 1 ? "1 Comment" : `${count} Comments`; 912 962 913 963 let contentHtml = items 914 964 .map((item) => { ··· 916 966 const handle = author.handle || "User"; 917 967 const avatar = author.avatar; 918 968 const text = item.body?.value || item.text || ""; 919 - const quote = 920 - item.target?.selector?.exact || item.selector?.exact || ""; 921 969 const id = item.id || item.uri; 970 + const isHighlight = item.type === "Highlight"; 922 971 923 - let avatarHtml = `<div class="popover-avatar">${handle[0]?.toUpperCase() || "U"}</div>`; 972 + let avatarHtml = `<div class="comment-avatar">${handle[0]?.toUpperCase() || "U"}</div>`; 924 973 if (avatar) { 925 - avatarHtml = `<img src="${avatar}" class="popover-avatar" style="object-fit: cover;">`; 974 + avatarHtml = `<img src="${avatar}" class="comment-avatar" style="object-fit: cover;">`; 926 975 } 927 976 928 - const isHighlight = item.type === "Highlight"; 929 - 930 977 let bodyHtml = ""; 931 - if (isHighlight) { 932 - bodyHtml = `<div class="popover-text" style="font-style: italic; color: #a1a1aa;">"${quote}"</div>`; 978 + if (isHighlight && !text) { 979 + bodyHtml = `<div class="highlight-only-badge">Highlighted</div>`; 933 980 } else { 934 - bodyHtml = `<div class="popover-text">${text}</div>`; 935 - if (quote) { 936 - bodyHtml += `<div class="popover-quote">"${quote}"</div>`; 937 - } 981 + bodyHtml = `<div class="comment-text">${text}</div>`; 938 982 } 939 983 940 984 return ` 941 - <div class="popover-item-block"> 942 - <div class="popover-item-header"> 943 - <div class="popover-author"> 944 - ${avatarHtml} 945 - <span class="popover-handle">@${handle}</span> 946 - </div> 947 - </div> 948 - <div class="popover-content"> 949 - ${bodyHtml} 950 - </div> 951 - <div class="popover-actions"> 952 - ${!isHighlight ? `<button class="btn-action btn-reply" data-id="${id}">Reply</button>` : ""} 953 - <button class="btn-action btn-share" data-id="${id}" data-text="${text}" data-quote="${quote}">Share</button> 954 - </div> 985 + <div class="comment-item"> 986 + <div class="comment-header"> 987 + ${avatarHtml} 988 + <span class="comment-handle">@${handle}</span> 989 + </div> 990 + ${bodyHtml} 991 + <div class="comment-actions"> 992 + ${!isHighlight ? `<button class="comment-action-btn btn-reply" data-id="${id}">Reply</button>` : ""} 993 + <button class="comment-action-btn btn-share" data-id="${id}" data-text="${text}">Share</button> 994 + </div> 955 995 </div> 956 996 `; 957 997 }) ··· 992 1032 btn.addEventListener("click", async () => { 993 1033 const id = btn.getAttribute("data-id"); 994 1034 const text = btn.getAttribute("data-text"); 995 - const quote = btn.getAttribute("data-quote"); 996 1035 const u = `https://margin.at/annotation/${encodeURIComponent(id)}`; 997 - const shareText = `${text ? text + "\n" : ""}${quote ? `"${quote}"\n` : ""}${u}`; 1036 + const shareText = text ? `${text}\n${u}` : u; 998 1037 999 1038 try { 1000 1039 await navigator.clipboard.writeText(shareText);
+166 -166
extension/popup/popup.css
··· 1 + @import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&display=swap"); 2 + 1 3 :root { 2 - --bg-primary: #09090b; 3 - --bg-secondary: #0f0f12; 4 - --bg-tertiary: #18181b; 5 - --bg-card: #09090b; 6 - --bg-elevated: #18181b; 7 - --bg-hover: #27272a; 4 + --bg-primary: #0a0a0d; 5 + --bg-secondary: #121216; 6 + --bg-tertiary: #1a1a1f; 7 + --bg-card: #0f0f13; 8 + --bg-elevated: #18181d; 9 + --bg-hover: #1e1e24; 8 10 9 - --text-primary: #e4e4e7; 10 - --text-secondary: #a1a1aa; 11 - --text-tertiary: #71717a; 12 - --border: #27272a; 13 - --border-hover: #3f3f46; 11 + --text-primary: #eaeaee; 12 + --text-secondary: #b7b6c5; 13 + --text-tertiary: #6e6d7a; 14 14 15 - --accent: #6366f1; 16 - --accent-hover: #4f46e5; 17 - --accent-subtle: rgba(99, 102, 241, 0.1); 18 - --accent-text: #818cf8; 19 - --success: #10b981; 20 - --error: #ef4444; 21 - --warning: #f59e0b; 15 + --border: rgba(183, 182, 197, 0.12); 16 + --border-hover: rgba(183, 182, 197, 0.2); 17 + 18 + --accent: #957a86; 19 + --accent-hover: #a98d98; 20 + --accent-subtle: rgba(149, 122, 134, 0.15); 21 + --accent-text: #c4a8b2; 22 + 23 + --success: #7fb069; 24 + --error: #d97766; 25 + --warning: #e8a54b; 22 26 23 - --radius-sm: 4px; 24 - --radius-md: 6px; 25 - --radius-lg: 8px; 27 + --radius-sm: 6px; 28 + --radius-md: 8px; 29 + --radius-lg: 12px; 26 30 --radius-full: 9999px; 27 - --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); 28 - --shadow-md: 29 - 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); 31 + 32 + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3); 33 + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4); 30 34 } 31 35 32 36 @media (prefers-color-scheme: light) { 33 37 :root { 34 - --bg-primary: #ffffff; 35 - --bg-secondary: #f4f4f5; 36 - --bg-tertiary: #e4e4e7; 38 + --bg-primary: #f8f8fa; 39 + --bg-secondary: #ffffff; 40 + --bg-tertiary: #f0f0f4; 37 41 --bg-card: #ffffff; 38 - --bg-elevated: #f4f4f5; 39 - --bg-hover: #e4e4e7; 42 + --bg-elevated: #ffffff; 43 + --bg-hover: #eeeef2; 40 44 41 - --text-primary: #18181b; 42 - --text-secondary: #52525b; 43 - --text-tertiary: #71717a; 44 - --border: #e4e4e7; 45 - --border-hover: #d4d4d8; 45 + --text-primary: #18171c; 46 + --text-secondary: #5c495a; 47 + --text-tertiary: #8a8494; 46 48 47 - --accent: #4f46e5; 48 - --accent-hover: #4338ca; 49 - --accent-text: #4f46e5; 50 - --accent-subtle: rgba(79, 70, 229, 0.1); 49 + --border: rgba(92, 73, 90, 0.12); 50 + --border-hover: rgba(92, 73, 90, 0.22); 51 51 52 - --success: #059669; 53 - --error: #dc2626; 54 - --warning: #d97706; 52 + --accent: #7a5f6d; 53 + --accent-hover: #664e5b; 54 + --accent-subtle: rgba(149, 122, 134, 0.12); 55 + --accent-text: #5c495a; 56 + 57 + --shadow-sm: 0 1px 3px rgba(92, 73, 90, 0.06); 58 + --shadow-md: 0 4px 12px rgba(92, 73, 90, 0.08); 55 59 } 56 60 } 57 61 58 62 body.light { 59 - --bg-primary: #ffffff; 60 - --bg-secondary: #f4f4f5; 61 - --bg-tertiary: #e4e4e7; 63 + --bg-primary: #f8f8fa; 64 + --bg-secondary: #ffffff; 65 + --bg-tertiary: #f0f0f4; 62 66 --bg-card: #ffffff; 63 - --bg-elevated: #f4f4f5; 64 - --bg-hover: #e4e4e7; 67 + --bg-elevated: #ffffff; 68 + --bg-hover: #eeeef2; 65 69 66 - --text-primary: #18181b; 67 - --text-secondary: #52525b; 68 - --text-tertiary: #71717a; 69 - --border: #e4e4e7; 70 - --border-hover: #d4d4d8; 70 + --text-primary: #18171c; 71 + --text-secondary: #5c495a; 72 + --text-tertiary: #8a8494; 71 73 72 - --accent: #4f46e5; 73 - --accent-hover: #4338ca; 74 - --accent-text: #4f46e5; 75 - --accent-subtle: rgba(79, 70, 229, 0.1); 74 + --border: rgba(92, 73, 90, 0.12); 75 + --border-hover: rgba(92, 73, 90, 0.22); 76 + 77 + --accent: #7a5f6d; 78 + --accent-hover: #664e5b; 79 + --accent-subtle: rgba(149, 122, 134, 0.12); 80 + --accent-text: #5c495a; 76 81 77 - --success: #059669; 78 - --error: #dc2626; 79 - --warning: #d97706; 82 + --shadow-sm: 0 1px 3px rgba(92, 73, 90, 0.06); 83 + --shadow-md: 0 4px 12px rgba(92, 73, 90, 0.08); 80 84 } 81 85 82 86 body.dark { 83 - --bg-primary: #09090b; 84 - --bg-secondary: #0f0f12; 85 - --bg-tertiary: #18181b; 86 - --bg-card: #09090b; 87 - --bg-elevated: #18181b; 88 - --bg-hover: #27272a; 87 + --bg-primary: #0a0a0d; 88 + --bg-secondary: #121216; 89 + --bg-tertiary: #1a1a1f; 90 + --bg-card: #0f0f13; 91 + --bg-elevated: #18181d; 92 + --bg-hover: #1e1e24; 93 + 94 + --text-primary: #eaeaee; 95 + --text-secondary: #b7b6c5; 96 + --text-tertiary: #6e6d7a; 97 + 98 + --border: rgba(183, 182, 197, 0.12); 99 + --border-hover: rgba(183, 182, 197, 0.2); 89 100 90 - --text-primary: #e4e4e7; 91 - --text-secondary: #a1a1aa; 92 - --text-tertiary: #71717a; 93 - --border: #27272a; 94 - --border-hover: #3f3f46; 101 + --accent: #957a86; 102 + --accent-hover: #a98d98; 103 + --accent-subtle: rgba(149, 122, 134, 0.15); 104 + --accent-text: #c4a8b2; 95 105 96 - --accent: #6366f1; 97 - --accent-hover: #4f46e5; 98 - --accent-subtle: rgba(99, 102, 241, 0.1); 99 - --accent-text: #818cf8; 100 - --success: #10b981; 101 - --error: #ef4444; 102 - --warning: #f59e0b; 106 + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3); 107 + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4); 103 108 } 104 109 105 110 * { ··· 111 116 body { 112 117 width: 380px; 113 118 height: 520px; 114 - font-family: "Inter", sans-serif; 119 + font-family: 120 + "IBM Plex Sans", 121 + -apple-system, 122 + BlinkMacSystemFont, 123 + sans-serif; 115 124 color: var(--text-primary); 116 125 background-color: var(--bg-primary); 117 126 overflow: hidden; 127 + -webkit-font-smoothing: antialiased; 118 128 } 119 129 120 130 .popup { ··· 129 139 display: flex; 130 140 justify-content: space-between; 131 141 align-items: center; 132 - background: var(--bg-secondary); 133 - z-index: 10; 142 + background: var(--bg-primary); 134 143 } 135 144 136 145 .popup-brand { ··· 145 154 146 155 .popup-title { 147 156 font-weight: 600; 148 - font-size: 16px; 157 + font-size: 15px; 149 158 color: var(--text-primary); 159 + letter-spacing: -0.02em; 150 160 } 151 161 152 162 .user-info { ··· 159 169 font-size: 12px; 160 170 color: var(--text-secondary); 161 171 background: var(--bg-tertiary); 162 - padding: 4px 8px; 163 - border-radius: var(--radius-sm); 172 + padding: 4px 10px; 173 + border-radius: var(--radius-full); 164 174 } 165 175 166 176 .tabs { 167 177 display: flex; 168 178 border-bottom: 1px solid var(--border); 169 - background: var(--bg-tertiary); 170 - padding: 4px; 179 + background: var(--bg-primary); 180 + padding: 4px 8px; 171 181 gap: 4px; 172 182 } 173 183 ··· 178 188 border: none; 179 189 font-size: 12px; 180 190 font-weight: 500; 181 - color: var(--text-secondary); 191 + color: var(--text-tertiary); 182 192 cursor: pointer; 183 193 border-radius: var(--radius-sm); 184 194 transition: all 0.15s; 185 195 } 186 196 187 197 .tab-btn:hover { 188 - color: var(--text-primary); 198 + color: var(--text-secondary); 189 199 background: var(--bg-hover); 190 200 } 191 201 192 202 .tab-btn.active { 193 203 color: var(--text-primary); 194 - background: var(--bg-card); 195 - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); 204 + background: var(--bg-tertiary); 196 205 } 197 206 198 207 .tab-content { ··· 220 229 align-items: center; 221 230 justify-content: center; 222 231 height: 100%; 223 - color: var(--text-secondary); 232 + color: var(--text-tertiary); 224 233 gap: 12px; 225 234 } 226 235 227 236 .spinner { 228 - width: 24px; 229 - height: 24px; 230 - border: 3px solid var(--border); 237 + width: 20px; 238 + height: 20px; 239 + border: 2px solid var(--border); 231 240 border-top-color: var(--accent); 232 241 border-radius: 50%; 233 242 animation: spin 1s linear infinite; ··· 251 260 } 252 261 253 262 .login-at-logo { 254 - font-size: 4rem; 255 - font-weight: 800; 263 + font-size: 3.5rem; 264 + font-weight: 700; 256 265 color: var(--accent); 257 266 line-height: 1; 258 267 } 259 268 260 269 .login-title { 261 - font-size: 1.1rem; 270 + font-size: 1rem; 262 271 font-weight: 600; 263 272 color: var(--text-primary); 264 273 } 265 274 266 275 .login-text { 267 - font-size: 14px; 276 + font-size: 13px; 268 277 color: var(--text-secondary); 269 278 line-height: 1.5; 270 279 } ··· 272 281 .quick-actions { 273 282 padding: 12px 16px; 274 283 border-bottom: 1px solid var(--border); 275 - background: var(--bg-secondary); 284 + background: var(--bg-primary); 276 285 } 277 286 278 287 .create-form { 279 288 padding: 16px; 280 289 border-bottom: 1px solid var(--border); 281 - background: var(--bg-secondary); 290 + background: var(--bg-primary); 282 291 } 283 292 284 293 .form-header { ··· 289 298 } 290 299 291 300 .form-title { 292 - font-size: 13px; 301 + font-size: 12px; 293 302 font-weight: 600; 294 303 color: var(--text-primary); 304 + letter-spacing: -0.01em; 295 305 } 296 306 297 307 .current-url { ··· 312 322 font-size: 13px; 313 323 resize: none; 314 324 margin-bottom: 10px; 315 - background: var(--bg-tertiary); 325 + background: var(--bg-elevated); 316 326 color: var(--text-primary); 317 - transition: 318 - border-color 0.15s, 319 - box-shadow 0.15s; 327 + transition: border-color 0.15s; 320 328 } 321 329 322 330 .annotation-input::placeholder { ··· 326 334 .annotation-input:focus { 327 335 outline: none; 328 336 border-color: var(--accent); 329 - box-shadow: 0 0 0 3px var(--accent-subtle); 330 337 } 331 338 332 339 .form-actions { ··· 338 345 margin-bottom: 12px; 339 346 padding: 10px 12px; 340 347 background: var(--accent-subtle); 341 - border: 1px solid var(--accent); 348 + border-left: 2px solid var(--accent); 342 349 border-radius: var(--radius-sm); 343 350 } 344 351 ··· 351 358 font-weight: 600; 352 359 text-transform: uppercase; 353 360 letter-spacing: 0.5px; 354 - color: var(--accent); 361 + color: var(--accent-text); 355 362 } 356 363 357 364 .quote-preview-clear { ··· 371 378 .quote-preview-text { 372 379 font-size: 12px; 373 380 font-style: italic; 374 - color: var(--text-primary); 381 + color: var(--text-secondary); 375 382 line-height: 1.4; 376 383 max-height: 60px; 377 384 overflow: hidden; ··· 386 393 justify-content: space-between; 387 394 align-items: center; 388 395 padding: 14px 16px; 389 - background: var(--bg-secondary); 396 + background: var(--bg-primary); 390 397 } 391 398 392 399 .section-title { ··· 401 408 font-size: 11px; 402 409 background: var(--bg-tertiary); 403 410 padding: 3px 8px; 404 - border-radius: 10px; 411 + border-radius: var(--radius-full); 405 412 color: var(--text-secondary); 406 413 } 407 414 408 415 .annotations { 409 416 display: flex; 410 417 flex-direction: column; 411 - gap: 10px; 412 - padding: 12px 16px; 418 + gap: 1px; 419 + background: var(--border); 413 420 } 414 421 415 422 .annotation-item { 416 - border: 1px solid var(--border); 417 - border-radius: var(--radius-md); 418 - padding: 12px; 419 - background: var(--bg-card); 420 - transition: border-color 0.15s; 423 + padding: 14px 16px; 424 + background: var(--bg-primary); 425 + transition: background 0.15s; 421 426 } 422 427 423 428 .annotation-item:hover { 424 - border-color: var(--border-hover); 429 + background: var(--bg-hover); 425 430 } 426 431 427 432 .annotation-item-header { ··· 432 437 } 433 438 434 439 .annotation-item-avatar { 435 - width: 28px; 436 - height: 28px; 440 + width: 26px; 441 + height: 26px; 437 442 border-radius: 50%; 438 - background: linear-gradient(135deg, var(--accent), #c084fc); 439 - color: white; 443 + background: var(--accent); 444 + color: var(--bg-primary); 440 445 display: flex; 441 446 align-items: center; 442 447 justify-content: center; 443 - font-size: 11px; 448 + font-size: 10px; 444 449 font-weight: 600; 445 450 } 446 451 ··· 462 467 .annotation-type-badge { 463 468 font-size: 10px; 464 469 padding: 3px 8px; 465 - border-radius: var(--radius-sm); 470 + border-radius: var(--radius-full); 466 471 font-weight: 500; 467 472 } 468 473 469 474 .annotation-type-badge.highlight { 470 - background: rgba(251, 191, 36, 0.2); 471 - color: #fbbf24; 475 + background: var(--accent-subtle); 476 + color: var(--accent-text); 472 477 } 473 478 474 479 .annotation-item-quote { 475 - padding: 10px 12px; 476 - border-left: 3px solid #fbbf24; 477 - margin-bottom: 10px; 478 - font-size: 13px; 480 + padding: 8px 12px; 481 + border-left: 2px solid var(--accent); 482 + margin-bottom: 8px; 483 + font-size: 12px; 479 484 color: var(--text-secondary); 480 485 font-style: italic; 481 - background: rgba(251, 191, 36, 0.1); 486 + background: var(--accent-subtle); 482 487 border-radius: 0 var(--radius-sm) var(--radius-sm) 0; 483 488 } 484 489 ··· 489 494 } 490 495 491 496 .bookmark-item { 492 - border: 1px solid var(--border); 493 - border-radius: var(--radius-md); 494 - padding: 12px; 495 - background: var(--bg-card); 497 + padding: 14px 16px; 498 + background: var(--bg-primary); 496 499 text-decoration: none; 497 500 color: inherit; 498 501 display: block; 499 - transition: border-color 0.15s; 502 + transition: background 0.15s; 500 503 } 501 504 502 505 .bookmark-item:hover { 503 - border-color: var(--accent); 506 + background: var(--bg-hover); 504 507 } 505 508 506 509 .bookmark-title { 507 - font-size: 14px; 510 + font-size: 13px; 508 511 font-weight: 500; 509 512 margin-bottom: 4px; 510 513 white-space: nowrap; ··· 534 537 .empty-icon { 535 538 margin-bottom: 12px; 536 539 color: var(--text-tertiary); 537 - opacity: 0.5; 540 + opacity: 0.4; 538 541 } 539 542 540 543 .empty-text { ··· 568 571 569 572 .btn-primary:hover { 570 573 background: var(--accent-hover); 571 - transform: translateY(-1px); 572 574 } 573 575 574 576 .btn-secondary { ··· 586 588 .btn-icon { 587 589 background: none; 588 590 border: none; 589 - color: var(--text-secondary); 591 + color: var(--text-tertiary); 590 592 cursor: pointer; 591 593 padding: 6px; 592 594 border-radius: var(--radius-sm); ··· 599 601 600 602 .popup-link { 601 603 font-size: 12px; 602 - color: var(--text-secondary); 604 + color: var(--text-tertiary); 603 605 text-decoration: none; 604 606 } 605 607 606 608 .popup-link:hover { 607 - color: var(--accent); 608 - text-decoration: underline; 609 + color: var(--accent-text); 609 610 } 610 611 611 612 .popup-footer { 612 613 padding: 12px 16px; 613 614 border-top: 1px solid var(--border); 614 - background: var(--bg-secondary); 615 + background: var(--bg-primary); 615 616 } 616 617 617 618 .settings-view { ··· 653 654 } 654 655 655 656 ::-webkit-scrollbar { 656 - width: 6px; 657 + width: 8px; 657 658 } 658 659 659 660 ::-webkit-scrollbar-track { 660 - background: var(--bg-secondary); 661 + background: transparent; 661 662 } 662 663 663 664 ::-webkit-scrollbar-thumb { 664 - background: var(--border); 665 - border-radius: 3px; 665 + background: var(--bg-hover); 666 + border-radius: var(--radius-full); 666 667 } 667 668 668 669 ::-webkit-scrollbar-thumb:hover { 669 - background: var(--border-hover); 670 + background: var(--text-tertiary); 670 671 } 671 672 672 673 .collection-selector { ··· 695 696 align-items: center; 696 697 gap: 12px; 697 698 padding: 12px; 698 - background: var(--bg-card); 699 + background: var(--bg-primary); 699 700 border: 1px solid var(--border); 700 701 border-radius: var(--radius-md); 701 702 color: var(--text-primary); ··· 711 712 } 712 713 713 714 .collection-select-btn:disabled { 714 - opacity: 0.7; 715 + opacity: 0.6; 715 716 cursor: not-allowed; 716 717 } 717 718 ··· 725 726 .toggle-switch { 726 727 position: relative; 727 728 display: inline-block; 728 - width: 44px; 729 - height: 24px; 729 + width: 40px; 730 + height: 22px; 730 731 flex-shrink: 0; 731 732 } 732 733 ··· 743 744 left: 0; 744 745 right: 0; 745 746 bottom: 0; 746 - background-color: var(--border); 747 + background-color: var(--bg-tertiary); 747 748 transition: 0.2s; 748 - border-radius: 24px; 749 + border-radius: 22px; 749 750 } 750 751 751 752 .toggle-slider:before { 752 753 position: absolute; 753 754 content: ""; 754 - height: 18px; 755 - width: 18px; 755 + height: 16px; 756 + width: 16px; 756 757 left: 3px; 757 758 bottom: 3px; 758 - background-color: var(--text-secondary); 759 + background-color: var(--text-tertiary); 759 760 transition: 0.2s; 760 761 border-radius: 50%; 761 762 } ··· 765 766 } 766 767 767 768 .toggle-switch input:checked + .toggle-slider:before { 768 - transform: translateX(20px); 769 + transform: translateX(18px); 769 770 background-color: white; 770 771 } 771 772 772 773 .settings-input { 773 774 width: 100%; 774 775 padding: 10px 12px; 775 - background: var(--bg-tertiary); 776 + background: var(--bg-elevated); 776 777 border: 1px solid var(--border); 777 778 border-radius: var(--radius-md); 778 779 color: var(--text-primary); ··· 783 784 outline: none; 784 785 border-color: var(--accent); 785 786 } 787 + 786 788 .theme-toggle-group { 787 789 display: flex; 788 790 background: var(--bg-tertiary); 789 - padding: 4px; 791 + padding: 3px; 790 792 border-radius: var(--radius-md); 791 793 gap: 2px; 792 794 margin-top: 8px; ··· 797 799 padding: 6px; 798 800 border: none; 799 801 background: transparent; 800 - color: var(--text-secondary); 802 + color: var(--text-tertiary); 801 803 font-size: 12px; 802 804 font-weight: 500; 803 805 border-radius: var(--radius-sm); ··· 806 808 } 807 809 808 810 .theme-btn:hover { 809 - color: var(--text-primary); 810 - background: rgba(128, 128, 128, 0.1); 811 + color: var(--text-secondary); 811 812 } 812 813 813 814 .theme-btn.active { 814 - background: var(--bg-card); 815 + background: var(--bg-primary); 815 816 color: var(--text-primary); 816 - box-shadow: var(--shadow-sm); 817 817 }
+164 -327
extension/sidepanel/sidepanel.css
··· 1 + @import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&display=swap"); 2 + 1 3 :root { 2 - --bg-primary: #09090b; 3 - --bg-secondary: #0f0f12; 4 - --bg-tertiary: #18181b; 5 - --bg-card: #09090b; 6 - --bg-hover: #18181b; 7 - --bg-elevated: #18181b; 4 + --bg-primary: #0a0a0d; 5 + --bg-secondary: #121216; 6 + --bg-tertiary: #1a1a1f; 7 + --bg-card: #0f0f13; 8 + --bg-elevated: #18181d; 9 + --bg-hover: #1e1e24; 8 10 9 - --text-primary: #e4e4e7; 10 - --text-secondary: #a1a1aa; 11 - --text-tertiary: #71717a; 11 + --text-primary: #eaeaee; 12 + --text-secondary: #b7b6c5; 13 + --text-tertiary: #6e6d7a; 12 14 13 - --accent: #6366f1; 14 - --accent-hover: #4f46e5; 15 - --accent-subtle: rgba(99, 102, 241, 0.1); 16 - --accent-text: #818cf8; 15 + --border: rgba(183, 182, 197, 0.12); 16 + --border-hover: rgba(183, 182, 197, 0.2); 17 17 18 - --border: #27272a; 19 - --border-hover: #3f3f46; 18 + --accent: #957a86; 19 + --accent-hover: #a98d98; 20 + --accent-subtle: rgba(149, 122, 134, 0.15); 21 + --accent-text: #c4a8b2; 20 22 21 - --success: #10b981; 22 - --error: #ef4444; 23 - --warning: #f59e0b; 23 + --success: #7fb069; 24 + --error: #d97766; 25 + --warning: #e8a54b; 24 26 25 - --radius-sm: 4px; 26 - --radius-md: 6px; 27 - --radius-lg: 8px; 27 + --radius-sm: 6px; 28 + --radius-md: 8px; 29 + --radius-lg: 12px; 28 30 --radius-full: 9999px; 29 31 30 - --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); 31 - --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1); 32 + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3); 33 + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4); 32 34 } 33 35 34 36 @media (prefers-color-scheme: light) { 35 37 :root { 36 - --bg-primary: #ffffff; 37 - --bg-secondary: #f4f4f5; 38 - --bg-tertiary: #e4e4e7; 38 + --bg-primary: #f8f8fa; 39 + --bg-secondary: #ffffff; 40 + --bg-tertiary: #f0f0f4; 39 41 --bg-card: #ffffff; 40 - --bg-hover: #e4e4e7; 41 - --bg-elevated: #f4f4f5; 42 + --bg-elevated: #ffffff; 43 + --bg-hover: #eeeef2; 42 44 43 - --text-primary: #18181b; 44 - --text-secondary: #52525b; 45 - --text-tertiary: #71717a; 45 + --text-primary: #18171c; 46 + --text-secondary: #5c495a; 47 + --text-tertiary: #8a8494; 46 48 47 - --accent: #4f46e5; 48 - --accent-hover: #4338ca; 49 - --accent-subtle: rgba(79, 70, 229, 0.1); 50 - --accent-text: #4f46e5; 49 + --border: rgba(92, 73, 90, 0.12); 50 + --border-hover: rgba(92, 73, 90, 0.22); 51 51 52 - --border: #e4e4e7; 53 - --border-hover: #d4d4d8; 52 + --accent: #7a5f6d; 53 + --accent-hover: #664e5b; 54 + --accent-subtle: rgba(149, 122, 134, 0.12); 55 + --accent-text: #5c495a; 54 56 55 - --success: #059669; 56 - --error: #dc2626; 57 - --warning: #d97706; 57 + --shadow-sm: 0 1px 3px rgba(92, 73, 90, 0.06); 58 + --shadow-md: 0 4px 12px rgba(92, 73, 90, 0.08); 58 59 } 59 60 } 60 61 61 62 body.light { 62 - --bg-primary: #ffffff; 63 - --bg-secondary: #f4f4f5; 64 - --bg-tertiary: #e4e4e7; 63 + --bg-primary: #f8f8fa; 64 + --bg-secondary: #ffffff; 65 + --bg-tertiary: #f0f0f4; 65 66 --bg-card: #ffffff; 66 - --bg-hover: #e4e4e7; 67 - --bg-elevated: #f4f4f5; 67 + --bg-elevated: #ffffff; 68 + --bg-hover: #eeeef2; 68 69 69 - --text-primary: #18181b; 70 - --text-secondary: #52525b; 71 - --text-tertiary: #71717a; 70 + --text-primary: #18171c; 71 + --text-secondary: #5c495a; 72 + --text-tertiary: #8a8494; 72 73 73 - --accent: #4f46e5; 74 - --accent-hover: #4338ca; 75 - --accent-subtle: rgba(79, 70, 229, 0.1); 76 - --accent-text: #4f46e5; 74 + --border: rgba(92, 73, 90, 0.12); 75 + --border-hover: rgba(92, 73, 90, 0.22); 77 76 78 - --border: #e4e4e7; 79 - --border-hover: #d4d4d8; 77 + --accent: #7a5f6d; 78 + --accent-hover: #664e5b; 79 + --accent-subtle: rgba(149, 122, 134, 0.12); 80 + --accent-text: #5c495a; 80 81 81 - --success: #059669; 82 - --error: #dc2626; 83 - --warning: #d97706; 82 + --shadow-sm: 0 1px 3px rgba(92, 73, 90, 0.06); 83 + --shadow-md: 0 4px 12px rgba(92, 73, 90, 0.08); 84 84 } 85 85 86 86 body.dark { 87 - --bg-primary: #09090b; 88 - --bg-secondary: #0f0f12; 89 - --bg-tertiary: #18181b; 90 - --bg-card: #09090b; 91 - --bg-hover: #18181b; 92 - --bg-elevated: #18181b; 87 + --bg-primary: #0a0a0d; 88 + --bg-secondary: #121216; 89 + --bg-tertiary: #1a1a1f; 90 + --bg-card: #0f0f13; 91 + --bg-elevated: #18181d; 92 + --bg-hover: #1e1e24; 93 93 94 - --text-primary: #e4e4e7; 95 - --text-secondary: #a1a1aa; 96 - --text-tertiary: #71717a; 94 + --text-primary: #eaeaee; 95 + --text-secondary: #b7b6c5; 96 + --text-tertiary: #6e6d7a; 97 97 98 - --accent: #6366f1; 99 - --accent-hover: #4f46e5; 100 - --accent-subtle: rgba(99, 102, 241, 0.1); 101 - --accent-text: #818cf8; 98 + --border: rgba(183, 182, 197, 0.12); 99 + --border-hover: rgba(183, 182, 197, 0.2); 102 100 103 - --border: #27272a; 104 - --border-hover: #3f3f46; 101 + --accent: #957a86; 102 + --accent-hover: #a98d98; 103 + --accent-subtle: rgba(149, 122, 134, 0.15); 104 + --accent-text: #c4a8b2; 105 105 106 - --success: #10b981; 107 - --error: #ef4444; 108 - --warning: #f59e0b; 106 + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3); 107 + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4); 109 108 } 110 109 111 110 * { ··· 116 115 117 116 body { 118 117 font-family: 119 - "Inter", 118 + "IBM Plex Sans", 120 119 -apple-system, 121 120 BlinkMacSystemFont, 122 - "Segoe UI", 123 121 sans-serif; 124 122 background: var(--bg-primary); 125 123 color: var(--text-primary); ··· 143 141 background: var(--bg-primary); 144 142 } 145 143 146 - .user-handle { 147 - font-size: 12px; 148 - color: var(--text-secondary); 149 - background: var(--bg-tertiary); 150 - padding: 4px 8px; 151 - border-radius: var(--radius-sm); 152 - } 153 - 154 - .current-page-info { 155 - display: flex; 156 - align-items: center; 157 - gap: 8px; 158 - padding: 10px 16px; 159 - background: var(--bg-primary); 160 - border-bottom: 1px solid var(--border); 161 - } 162 - 163 - .tabs { 164 - display: flex; 165 - border-bottom: 1px solid var(--border); 166 - background: var(--bg-primary); 167 - padding: 4px; 168 - gap: 4px; 169 - margin: 0; 170 - } 171 - 172 - .tab-btn { 173 - flex: 1; 174 - padding: 10px 8px; 175 - background: transparent; 176 - border: none; 177 - font-size: 12px; 178 - font-weight: 500; 179 - color: var(--text-secondary); 180 - cursor: pointer; 181 - border-radius: var(--radius-sm); 182 - transition: all 0.15s; 183 - } 184 - 185 - .tab-btn:hover { 186 - color: var(--text-primary); 187 - background: var(--bg-hover); 188 - } 189 - 190 - .tab-btn.active { 191 - color: var(--text-primary); 192 - background: var(--bg-tertiary); 193 - box-shadow: none; 194 - } 195 - 196 - .quick-actions { 197 - display: flex; 198 - gap: 8px; 199 - padding: 12px 16px; 200 - border-bottom: 1px solid var(--border); 201 - background: var(--bg-primary); 202 - } 203 - 204 - .create-form { 205 - padding: 16px; 206 - border-bottom: 1px solid var(--border); 207 - background: var(--bg-primary); 208 - } 209 - 210 - .section-header { 211 - display: flex; 212 - justify-content: space-between; 213 - align-items: center; 214 - padding: 14px 16px; 215 - background: var(--bg-primary); 216 - border-bottom: 1px solid var(--border); 217 - } 218 - 219 - .annotation-item { 220 - border: 1px solid var(--border); 221 - border-radius: var(--radius-md); 222 - padding: 12px; 223 - background: var(--bg-primary); 224 - transition: border-color 0.15s; 225 - } 226 - 227 - .annotation-item:hover { 228 - border-color: var(--border-hover); 229 - background: var(--bg-hover); 230 - } 231 - 232 - .sidebar-footer { 233 - display: flex; 234 - align-items: center; 235 - justify-content: space-between; 236 - padding: 12px 16px; 237 - border-top: 1px solid var(--border); 238 - background: var(--bg-primary); 239 - } 240 - 241 - ::-webkit-scrollbar { 242 - width: 10px; 243 - height: 10px; 244 - } 245 - 246 - ::-webkit-scrollbar-track { 247 - background: transparent; 248 - } 249 - 250 - ::-webkit-scrollbar-thumb { 251 - background: var(--border); 252 - border-radius: 5px; 253 - border: 2px solid var(--bg-primary); 254 - } 255 - 256 - ::-webkit-scrollbar-thumb:hover { 257 - background: var(--border-hover); 258 - } 259 - 260 - * { 261 - margin: 0; 262 - padding: 0; 263 - box-sizing: border-box; 264 - } 265 - 266 - body { 267 - font-family: 268 - "Inter", 269 - -apple-system, 270 - BlinkMacSystemFont, 271 - "Segoe UI", 272 - sans-serif; 273 - background: var(--bg-primary); 274 - color: var(--text-primary); 275 - min-height: 100vh; 276 - -webkit-font-smoothing: antialiased; 277 - } 278 - 279 - .sidebar { 280 - display: flex; 281 - flex-direction: column; 282 - height: 100vh; 283 - background: var(--bg-primary); 284 - } 285 - 286 - .sidebar-header { 287 - display: flex; 288 - align-items: center; 289 - justify-content: space-between; 290 - padding: 14px 16px; 291 - border-bottom: 1px solid var(--border); 292 - background: var(--bg-secondary); 293 - } 294 - 295 144 .sidebar-brand { 296 145 display: flex; 297 146 align-items: center; ··· 304 153 305 154 .sidebar-title { 306 155 font-weight: 600; 307 - font-size: 16px; 156 + font-size: 15px; 308 157 color: var(--text-primary); 158 + letter-spacing: -0.02em; 309 159 } 310 160 311 161 .user-info { ··· 318 168 font-size: 12px; 319 169 color: var(--text-secondary); 320 170 background: var(--bg-tertiary); 321 - padding: 4px 8px; 322 - border-radius: var(--radius-sm); 171 + padding: 4px 10px; 172 + border-radius: var(--radius-full); 323 173 } 324 174 325 175 .current-page-info { ··· 327 177 align-items: center; 328 178 gap: 8px; 329 179 padding: 10px 16px; 330 - background: var(--bg-tertiary); 180 + background: var(--bg-primary); 331 181 border-bottom: 1px solid var(--border); 332 182 } 333 183 334 184 .page-url { 335 185 font-size: 12px; 336 - color: var(--text-secondary); 186 + color: var(--text-tertiary); 337 187 white-space: nowrap; 338 188 overflow: hidden; 339 189 text-overflow: ellipsis; ··· 352 202 align-items: center; 353 203 justify-content: center; 354 204 height: 100%; 355 - color: var(--text-secondary); 205 + color: var(--text-tertiary); 356 206 gap: 12px; 357 207 } 358 208 359 209 .spinner { 360 - width: 24px; 361 - height: 24px; 362 - border: 3px solid var(--border); 210 + width: 20px; 211 + height: 20px; 212 + border: 2px solid var(--border); 363 213 border-top-color: var(--accent); 364 214 border-radius: 50%; 365 215 animation: spin 1s linear infinite; ··· 383 233 } 384 234 385 235 .login-at-logo { 386 - font-size: 4rem; 387 - font-weight: 800; 236 + font-size: 3.5rem; 237 + font-weight: 700; 388 238 color: var(--accent); 389 239 line-height: 1; 390 240 } 391 241 392 242 .login-title { 393 - font-size: 1.1rem; 243 + font-size: 1rem; 394 244 font-weight: 600; 395 245 color: var(--text-primary); 396 246 } 397 247 398 248 .login-text { 399 - font-size: 14px; 249 + font-size: 13px; 400 250 color: var(--text-secondary); 401 251 line-height: 1.5; 402 252 } ··· 404 254 .tabs { 405 255 display: flex; 406 256 border-bottom: 1px solid var(--border); 407 - background: var(--bg-tertiary); 408 - padding: 4px; 257 + background: var(--bg-primary); 258 + padding: 4px 8px; 409 259 gap: 4px; 410 260 margin: 0; 411 261 } ··· 417 267 border: none; 418 268 font-size: 12px; 419 269 font-weight: 500; 420 - color: var(--text-secondary); 270 + color: var(--text-tertiary); 421 271 cursor: pointer; 422 272 border-radius: var(--radius-sm); 423 273 transition: all 0.15s; 424 274 } 425 275 426 276 .tab-btn:hover { 427 - color: var(--text-primary); 277 + color: var(--text-secondary); 428 278 background: var(--bg-hover); 429 279 } 430 280 431 281 .tab-btn.active { 432 282 color: var(--text-primary); 433 - background: var(--bg-card); 434 - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); 283 + background: var(--bg-tertiary); 435 284 } 436 285 437 286 .tab-content { ··· 450 299 gap: 8px; 451 300 padding: 12px 16px; 452 301 border-bottom: 1px solid var(--border); 453 - background: var(--bg-secondary); 302 + background: var(--bg-primary); 454 303 } 455 304 456 305 .btn { ··· 479 328 480 329 .btn-primary:hover { 481 330 background: var(--accent-hover); 482 - transform: translateY(-1px); 483 331 } 484 332 485 333 .btn-primary:disabled { 486 334 opacity: 0.5; 487 335 cursor: not-allowed; 488 - transform: none; 489 336 } 490 337 491 338 .btn-secondary { ··· 507 354 .btn-icon { 508 355 background: none; 509 356 border: none; 510 - color: var(--text-secondary); 357 + color: var(--text-tertiary); 511 358 cursor: pointer; 512 359 padding: 6px; 513 360 border-radius: var(--radius-sm); ··· 521 368 .create-form { 522 369 padding: 16px; 523 370 border-bottom: 1px solid var(--border); 524 - background: var(--bg-secondary); 371 + background: var(--bg-primary); 525 372 } 526 373 527 374 .form-header { ··· 532 379 } 533 380 534 381 .form-title { 535 - font-size: 13px; 382 + font-size: 12px; 536 383 font-weight: 600; 537 384 color: var(--text-primary); 385 + letter-spacing: -0.01em; 538 386 } 539 387 540 388 .annotation-input { ··· 546 394 font-size: 13px; 547 395 resize: none; 548 396 margin-bottom: 10px; 549 - background: var(--bg-tertiary); 397 + background: var(--bg-elevated); 550 398 color: var(--text-primary); 551 - transition: 552 - border-color 0.15s, 553 - box-shadow 0.15s; 399 + transition: border-color 0.15s; 554 400 } 555 401 556 402 .annotation-input::placeholder { ··· 560 406 .annotation-input:focus { 561 407 outline: none; 562 408 border-color: var(--accent); 563 - box-shadow: 0 0 0 3px var(--accent-subtle); 564 409 } 565 410 566 411 .form-actions { ··· 572 417 margin-bottom: 12px; 573 418 padding: 12px; 574 419 background: var(--accent-subtle); 575 - border: 1px solid var(--accent); 576 - border-radius: var(--radius-md); 420 + border-left: 2px solid var(--accent); 421 + border-radius: var(--radius-sm); 577 422 } 578 423 579 424 .quote-preview-header { ··· 581 426 justify-content: space-between; 582 427 align-items: center; 583 428 margin-bottom: 8px; 584 - font-size: 11px; 429 + font-size: 10px; 585 430 font-weight: 600; 586 431 text-transform: uppercase; 587 432 letter-spacing: 0.5px; 588 - color: var(--accent); 433 + color: var(--accent-text); 589 434 } 590 435 591 436 .quote-preview-clear { ··· 603 448 } 604 449 605 450 .quote-preview-text { 606 - font-size: 13px; 451 + font-size: 12px; 607 452 font-style: italic; 608 - color: var(--text-primary); 453 + color: var(--text-secondary); 609 454 line-height: 1.5; 610 455 } 611 456 ··· 618 463 justify-content: space-between; 619 464 align-items: center; 620 465 padding: 14px 16px; 621 - background: var(--bg-secondary); 466 + background: var(--bg-primary); 467 + border-bottom: 1px solid var(--border); 622 468 } 623 469 624 470 .section-title { ··· 633 479 font-size: 11px; 634 480 background: var(--bg-tertiary); 635 481 padding: 3px 8px; 636 - border-radius: 10px; 482 + border-radius: var(--radius-full); 637 483 color: var(--text-secondary); 638 484 } 639 485 640 486 .annotations-list { 641 487 display: flex; 642 488 flex-direction: column; 643 - gap: 10px; 644 - padding: 12px 16px; 489 + gap: 1px; 490 + background: var(--border); 645 491 } 646 492 647 493 .annotation-item { 648 - border: 1px solid var(--border); 649 - border-radius: var(--radius-md); 650 - padding: 12px; 651 - background: var(--bg-card); 652 - transition: border-color 0.15s; 494 + padding: 14px 16px; 495 + background: var(--bg-primary); 496 + transition: background 0.15s; 653 497 } 654 498 655 499 .annotation-item:hover { 656 - border-color: var(--border-hover); 500 + background: var(--bg-hover); 657 501 } 658 502 659 503 .annotation-item-header { ··· 664 508 } 665 509 666 510 .annotation-item-avatar { 667 - width: 28px; 668 - height: 28px; 511 + width: 26px; 512 + height: 26px; 669 513 border-radius: 50%; 670 - background: linear-gradient(135deg, var(--accent), #c084fc); 671 - color: white; 514 + background: var(--accent); 515 + color: var(--bg-primary); 672 516 display: flex; 673 517 align-items: center; 674 518 justify-content: center; 675 - font-size: 11px; 519 + font-size: 10px; 676 520 font-weight: 600; 677 521 } 678 522 ··· 694 538 .annotation-type-badge { 695 539 font-size: 10px; 696 540 padding: 3px 8px; 697 - border-radius: var(--radius-sm); 541 + border-radius: var(--radius-full); 698 542 font-weight: 500; 699 543 } 700 544 701 545 .annotation-type-badge.highlight { 702 - background: rgba(251, 191, 36, 0.2); 703 - color: #fbbf24; 546 + background: var(--accent-subtle); 547 + color: var(--accent-text); 704 548 } 705 549 706 550 .annotation-item-quote { 707 - padding: 10px 12px; 708 - border-left: 3px solid #fbbf24; 709 - margin-bottom: 10px; 710 - font-size: 13px; 551 + padding: 8px 12px; 552 + border-left: 2px solid var(--accent); 553 + margin-bottom: 8px; 554 + font-size: 12px; 711 555 color: var(--text-secondary); 712 556 font-style: italic; 713 - background: rgba(251, 191, 36, 0.1); 557 + background: var(--accent-subtle); 714 558 border-radius: 0 var(--radius-sm) var(--radius-sm) 0; 715 559 } 716 560 ··· 723 567 .bookmarks-list { 724 568 display: flex; 725 569 flex-direction: column; 726 - gap: 10px; 727 - padding: 12px 16px; 570 + gap: 1px; 571 + background: var(--border); 728 572 } 729 573 730 574 .bookmark-item { 731 - border: 1px solid var(--border); 732 - border-radius: var(--radius-md); 733 - padding: 12px; 734 - background: var(--bg-card); 575 + padding: 14px 16px; 576 + background: var(--bg-primary); 735 577 text-decoration: none; 736 578 color: inherit; 737 579 display: block; 738 - transition: border-color 0.15s; 580 + transition: background 0.15s; 739 581 } 740 582 741 583 .bookmark-item:hover { 742 - border-color: var(--accent); 584 + background: var(--bg-hover); 743 585 } 744 586 745 587 .bookmark-title { 746 - font-size: 14px; 588 + font-size: 13px; 747 589 font-weight: 500; 748 590 margin-bottom: 4px; 749 591 white-space: nowrap; ··· 773 615 .empty-icon { 774 616 margin-bottom: 12px; 775 617 color: var(--text-tertiary); 776 - opacity: 0.5; 618 + opacity: 0.4; 777 619 } 778 620 779 621 .empty-text { ··· 793 635 justify-content: space-between; 794 636 padding: 12px 16px; 795 637 border-top: 1px solid var(--border); 796 - background: var(--bg-secondary); 638 + background: var(--bg-primary); 797 639 } 798 640 799 641 .sidebar-link { 800 642 font-size: 12px; 801 - color: var(--text-secondary); 643 + color: var(--text-tertiary); 802 644 text-decoration: none; 803 645 } 804 646 805 647 .sidebar-link:hover { 806 - color: var(--accent); 807 - text-decoration: underline; 648 + color: var(--accent-text); 808 649 } 809 650 810 651 .settings-view { ··· 852 693 border-radius: var(--radius-md); 853 694 font-family: inherit; 854 695 font-size: 13px; 855 - background: var(--bg-tertiary); 696 + background: var(--bg-elevated); 856 697 color: var(--text-primary); 857 - transition: 858 - border-color 0.15s, 859 - box-shadow 0.15s; 698 + transition: border-color 0.15s; 860 699 } 861 700 862 701 .settings-input:focus { 863 702 outline: none; 864 703 border-color: var(--accent); 865 - box-shadow: 0 0 0 3px var(--accent-subtle); 866 704 } 867 705 868 706 .setting-help { ··· 877 715 gap: 4px; 878 716 padding: 6px 10px; 879 717 font-size: 11px; 880 - color: var(--accent); 718 + color: var(--accent-text); 881 719 background: var(--accent-subtle); 882 720 border: none; 883 721 border-radius: var(--radius-sm); ··· 887 725 } 888 726 889 727 .scroll-to-btn:hover { 890 - background: rgba(168, 85, 247, 0.25); 728 + background: rgba(149, 122, 134, 0.25); 891 729 } 892 730 893 731 ::-webkit-scrollbar { 894 - width: 6px; 732 + width: 8px; 895 733 } 896 734 897 735 ::-webkit-scrollbar-track { 898 - background: var(--bg-secondary); 736 + background: transparent; 899 737 } 900 738 901 739 ::-webkit-scrollbar-thumb { 902 - background: var(--border); 903 - border-radius: 3px; 740 + background: var(--bg-hover); 741 + border-radius: var(--radius-full); 904 742 } 905 743 906 744 ::-webkit-scrollbar-thumb:hover { 907 - background: var(--border-hover); 745 + background: var(--text-tertiary); 908 746 } 909 747 910 748 .collection-selector { ··· 933 771 align-items: center; 934 772 gap: 12px; 935 773 padding: 12px; 936 - background: var(--bg-card); 774 + background: var(--bg-primary); 937 775 border: 1px solid var(--border); 938 776 border-radius: var(--radius-md); 939 777 color: var(--text-primary); ··· 949 787 } 950 788 951 789 .collection-select-btn:disabled { 952 - opacity: 0.7; 790 + opacity: 0.6; 953 791 cursor: not-allowed; 954 792 } 955 793 ··· 963 801 .toggle-switch { 964 802 position: relative; 965 803 display: inline-block; 966 - width: 44px; 967 - height: 24px; 804 + width: 40px; 805 + height: 22px; 968 806 flex-shrink: 0; 969 807 } 970 808 ··· 981 819 left: 0; 982 820 right: 0; 983 821 bottom: 0; 984 - background-color: var(--border); 822 + background-color: var(--bg-tertiary); 985 823 transition: 0.2s; 986 - border-radius: 24px; 824 + border-radius: 22px; 987 825 } 988 826 989 827 .toggle-slider:before { 990 828 position: absolute; 991 829 content: ""; 992 - height: 18px; 993 - width: 18px; 830 + height: 16px; 831 + width: 16px; 994 832 left: 3px; 995 833 bottom: 3px; 996 - background-color: var(--text-secondary); 834 + background-color: var(--text-tertiary); 997 835 transition: 0.2s; 998 836 border-radius: 50%; 999 837 } ··· 1003 841 } 1004 842 1005 843 .toggle-switch input:checked + .toggle-slider:before { 1006 - transform: translateX(20px); 844 + transform: translateX(18px); 1007 845 background-color: white; 1008 846 } 847 + 1009 848 .theme-toggle-group { 1010 849 display: flex; 1011 850 background: var(--bg-tertiary); 1012 - padding: 4px; 851 + padding: 3px; 1013 852 border-radius: var(--radius-md); 1014 853 gap: 2px; 1015 854 margin-top: 8px; ··· 1020 859 padding: 6px; 1021 860 border: none; 1022 861 background: transparent; 1023 - color: var(--text-secondary); 862 + color: var(--text-tertiary); 1024 863 font-size: 12px; 1025 864 font-weight: 500; 1026 865 border-radius: var(--radius-sm); ··· 1029 868 } 1030 869 1031 870 .theme-btn:hover { 1032 - color: var(--text-primary); 1033 - background: rgba(128, 128, 128, 0.1); 871 + color: var(--text-secondary); 1034 872 } 1035 873 1036 874 .theme-btn.active { 1037 - background: var(--bg-card); 875 + background: var(--bg-primary); 1038 876 color: var(--text-primary); 1039 - box-shadow: var(--shadow-sm); 1040 877 }
+1 -1
web/index.html
··· 12 12 <link rel="preconnect" href="https://fonts.googleapis.com" /> 13 13 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> 14 14 <link 15 - href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" 15 + href="https://fonts.googleapis.com/css2?family=Instrument+Sans:ital,wght@0,400..700;1,400..700&family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&display=swap" 16 16 rel="stylesheet" 17 17 /> 18 18 </head>
+11
web/package-lock.json
··· 8 8 "name": "margin-web", 9 9 "version": "0.0.1", 10 10 "dependencies": { 11 + "date-fns": "^4.1.0", 11 12 "lucide-react": "^0.562.0", 12 13 "react": "^18.3.1", 13 14 "react-dom": "^18.3.1", ··· 1941 1942 }, 1942 1943 "funding": { 1943 1944 "url": "https://github.com/sponsors/ljharb" 1945 + } 1946 + }, 1947 + "node_modules/date-fns": { 1948 + "version": "4.1.0", 1949 + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", 1950 + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", 1951 + "license": "MIT", 1952 + "funding": { 1953 + "type": "github", 1954 + "url": "https://github.com/sponsors/kossnocorp" 1944 1955 } 1945 1956 }, 1946 1957 "node_modules/debug": {
+1
web/package.json
··· 10 10 "preview": "vite preview" 11 11 }, 12 12 "dependencies": { 13 + "date-fns": "^4.1.0", 13 14 "lucide-react": "^0.562.0", 14 15 "react": "^18.3.1", 15 16 "react-dom": "^18.3.1",
+42 -44
web/src/App.jsx
··· 1 1 import { Routes, Route } from "react-router-dom"; 2 2 import { useEffect } from "react"; 3 3 import { AuthProvider, useAuth } from "./context/AuthContext"; 4 - import Sidebar from "./components/Sidebar"; 5 - import RightSidebar from "./components/RightSidebar"; 4 + import TopNav from "./components/TopNav"; 6 5 import MobileNav from "./components/MobileNav"; 7 6 import Feed from "./pages/Feed"; 8 7 import Url from "./pages/Url"; ··· 18 17 import CollectionDetail from "./pages/CollectionDetail"; 19 18 import Privacy from "./pages/Privacy"; 20 19 import Terms from "./pages/Terms"; 20 + import Landing from "./pages/Landing"; 21 21 import ScrollToTop from "./components/ScrollToTop"; 22 22 import { ThemeProvider } from "./context/ThemeContext"; 23 23 ··· 31 31 }, [user]); 32 32 33 33 return ( 34 - <div className="layout"> 34 + <div className="app"> 35 35 <ScrollToTop /> 36 - <Sidebar /> 37 - <div className="main-layout"> 38 - <main className="main-content-wrapper"> 39 - <Routes> 40 - <Route path="/" element={<Feed />} /> 41 - <Route path="/url" element={<Url />} /> 42 - <Route path="/new" element={<New />} /> 43 - <Route path="/bookmarks" element={<Bookmarks />} /> 44 - <Route path="/highlights" element={<Highlights />} /> 45 - <Route path="/notifications" element={<Notifications />} /> 46 - <Route path="/profile" element={<Profile />} /> 47 - <Route path="/profile/:handle" element={<Profile />} /> 48 - <Route path="/login" element={<Login />} /> 49 - <Route path="/at/:did/:rkey" element={<AnnotationDetail />} /> 50 - <Route path="/annotation/:uri" element={<AnnotationDetail />} /> 51 - <Route path="/collections" element={<Collections />} /> 52 - <Route path="/collections/:rkey" element={<CollectionDetail />} /> 53 - <Route 54 - path="/:handle/collection/:rkey" 55 - element={<CollectionDetail />} 56 - /> 57 - <Route 58 - path="/:handle/annotation/:rkey" 59 - element={<AnnotationDetail />} 60 - /> 61 - <Route 62 - path="/:handle/highlight/:rkey" 63 - element={<AnnotationDetail />} 64 - /> 65 - <Route 66 - path="/:handle/bookmark/:rkey" 67 - element={<AnnotationDetail />} 68 - /> 69 - <Route path="/:handle/url/*" element={<UserUrl />} /> 70 - <Route path="/collection/*" element={<CollectionDetail />} /> 71 - <Route path="/privacy" element={<Privacy />} /> 72 - <Route path="/terms" element={<Terms />} /> 73 - </Routes> 74 - </main> 75 - </div> 76 - <RightSidebar /> 36 + <TopNav /> 37 + <main className="main-content"> 38 + <Routes> 39 + <Route path="/home" element={<Feed />} /> 40 + <Route path="/url" element={<Url />} /> 41 + <Route path="/new" element={<New />} /> 42 + <Route path="/bookmarks" element={<Bookmarks />} /> 43 + <Route path="/highlights" element={<Highlights />} /> 44 + <Route path="/notifications" element={<Notifications />} /> 45 + <Route path="/profile" element={<Profile />} /> 46 + <Route path="/profile/:handle" element={<Profile />} /> 47 + <Route path="/login" element={<Login />} /> 48 + <Route path="/at/:did/:rkey" element={<AnnotationDetail />} /> 49 + <Route path="/annotation/:uri" element={<AnnotationDetail />} /> 50 + <Route path="/collections" element={<Collections />} /> 51 + <Route path="/collections/:rkey" element={<CollectionDetail />} /> 52 + <Route 53 + path="/:handle/collection/:rkey" 54 + element={<CollectionDetail />} 55 + /> 56 + <Route 57 + path="/:handle/annotation/:rkey" 58 + element={<AnnotationDetail />} 59 + /> 60 + <Route 61 + path="/:handle/highlight/:rkey" 62 + element={<AnnotationDetail />} 63 + /> 64 + <Route 65 + path="/:handle/bookmark/:rkey" 66 + element={<AnnotationDetail />} 67 + /> 68 + <Route path="/:handle/url/*" element={<UserUrl />} /> 69 + <Route path="/collection/*" element={<CollectionDetail />} /> 70 + <Route path="/privacy" element={<Privacy />} /> 71 + <Route path="/terms" element={<Terms />} /> 72 + </Routes> 73 + </main> 77 74 <MobileNav /> 78 75 </div> 79 76 ); ··· 84 81 <ThemeProvider> 85 82 <AuthProvider> 86 83 <Routes> 84 + <Route path="/" element={<Landing />} /> 87 85 <Route path="/*" element={<AppContent />} /> 88 86 </Routes> 89 87 </AuthProvider>
+7
web/src/api/client.js
··· 452 452 body: JSON.stringify({ handle, invite_code: inviteCode }), 453 453 }); 454 454 } 455 + 456 + export async function startSignup(pdsUrl) { 457 + return request(`${AUTH_BASE}/signup`, { 458 + method: "POST", 459 + body: JSON.stringify({ pds_url: pdsUrl }), 460 + }); 461 + } 455 462 export async function getTrendingTags(limit = 10) { 456 463 return request(`${API_BASE}/tags/trending?limit=${limit}`); 457 464 }
+135 -239
web/src/components/AnnotationCard.jsx
··· 34 34 if (!selector || selector.type !== "TextQuoteSelector" || !selector.exact) { 35 35 return baseUrl; 36 36 } 37 - 38 37 let fragment = ":~:text="; 39 38 if (selector.prefix) { 40 39 fragment += encodeURIComponent(selector.prefix) + "-,"; ··· 43 42 if (selector.suffix) { 44 43 fragment += ",-" + encodeURIComponent(selector.suffix); 45 44 } 46 - 47 45 return baseUrl + "#" + fragment; 48 46 } 49 47 50 - const truncateUrl = (url, maxLength = 60) => { 48 + const truncateUrl = (url, maxLength = 50) => { 51 49 if (!url) return ""; 52 50 try { 53 51 const parsed = new URL(url); ··· 60 58 } 61 59 }; 62 60 61 + function SembleBadge() { 62 + return ( 63 + <div className="semble-badge" title="Added using Semble"> 64 + <span>via Semble</span> 65 + <img src="/semble-logo.svg" alt="Semble" /> 66 + </div> 67 + ); 68 + } 69 + 63 70 export default function AnnotationCard({ 64 71 annotation, 65 72 onDelete, ··· 75 82 const [editText, setEditText] = useState(data.text || ""); 76 83 const [editTags, setEditTags] = useState(data.tags?.join(", ") || ""); 77 84 const [saving, setSaving] = useState(false); 78 - 79 85 const [showHistory, setShowHistory] = useState(false); 80 86 const [editHistory, setEditHistory] = useState([]); 81 87 const [loadingHistory, setLoadingHistory] = useState(false); 82 - 83 88 const [replies, setReplies] = useState([]); 84 89 const [replyCount, setReplyCount] = useState(data.replyCount || 0); 85 90 const [showReplies, setShowReplies] = useState(false); 86 91 const [replyingTo, setReplyingTo] = useState(null); 87 92 const [replyText, setReplyText] = useState(""); 88 93 const [posting, setPosting] = useState(false); 94 + const [hasEditHistory, setHasEditHistory] = useState(false); 89 95 90 96 const isOwner = user?.did && data.author?.did === user.did; 91 - 92 - const [hasEditHistory, setHasEditHistory] = useState(false); 97 + const isSemble = data.uri?.includes("network.cosmik"); 98 + const highlightedText = 99 + data.selector?.type === "TextQuoteSelector" ? data.selector.exact : null; 100 + const fragmentUrl = buildTextFragmentUrl(data.url, data.selector); 93 101 94 102 useEffect(() => { 95 103 if (data.uri && !data.color && !data.description) { 96 104 getEditHistory(data.uri) 97 105 .then((history) => { 98 - if (history && history.length > 0) { 99 - setHasEditHistory(true); 100 - } 106 + if (history?.length > 0) setHasEditHistory(true); 101 107 }) 102 108 .catch(() => {}); 103 109 } ··· 122 128 123 129 const handlePostReply = async (parentReply) => { 124 130 if (!replyText.trim()) return; 125 - 126 131 try { 127 132 setPosting(true); 128 133 const parentUri = parentReply ··· 175 180 } 176 181 }; 177 182 178 - const highlightedText = 179 - data.selector?.type === "TextQuoteSelector" ? data.selector.exact : null; 180 - const fragmentUrl = buildTextFragmentUrl(data.url, data.selector); 181 - 182 183 const handleLike = async () => { 183 184 if (!user) { 184 185 login(); ··· 195 196 const cid = annotation.cid || data.cid || ""; 196 197 if (data.uri && cid) await likeAnnotation(data.uri, cid); 197 198 } 198 - } catch (err) { 199 + } catch { 199 200 setIsLiked(!isLiked); 200 201 setLikeCount((prev) => (isLiked ? prev + 1 : prev - 1)); 201 - console.error("Failed to toggle like:", err); 202 202 } 203 203 }; 204 204 ··· 218 218 } 219 219 }; 220 220 221 + const loadReplies = async () => { 222 + if (!showReplies && replies.length === 0) { 223 + try { 224 + const res = await getReplies(data.uri); 225 + if (res.items) setReplies(res.items); 226 + } catch (err) { 227 + console.error("Failed to load replies:", err); 228 + } 229 + } 230 + setShowReplies(!showReplies); 231 + }; 232 + 233 + const handleCollect = () => { 234 + if (!user) { 235 + login(); 236 + return; 237 + } 238 + if (onAddToCollection) onAddToCollection(); 239 + }; 240 + 221 241 return ( 222 242 <article className="card annotation-card"> 223 243 <header className="annotation-header"> ··· 225 245 <UserMeta author={data.author} createdAt={data.createdAt} /> 226 246 </div> 227 247 <div className="annotation-header-right"> 228 - <div style={{ display: "flex", gap: "4px", alignItems: "center" }}> 229 - {data.uri && data.uri.includes("network.cosmik") && ( 230 - <div 231 - style={{ 232 - display: "flex", 233 - alignItems: "center", 234 - gap: "4px", 235 - fontSize: "0.75rem", 236 - color: "var(--text-tertiary)", 237 - marginRight: "8px", 238 - }} 239 - title="Added using Semble" 240 - > 241 - <span>via Semble</span> 242 - <img 243 - src="/semble-logo.svg" 244 - alt="Semble" 245 - style={{ width: "16px", height: "16px" }} 246 - /> 247 - </div> 248 - )} 249 - {hasEditHistory && !data.color && !data.description && ( 248 + {isSemble && <SembleBadge />} 249 + {hasEditHistory && !data.color && !data.description && ( 250 + <button 251 + className="annotation-action action-icon-only" 252 + onClick={fetchHistory} 253 + title="View Edit History" 254 + > 255 + <Clock size={16} /> 256 + </button> 257 + )} 258 + {isOwner && !isSemble && ( 259 + <> 260 + {!data.color && !data.description && ( 261 + <button 262 + className="annotation-action action-icon-only" 263 + onClick={() => setIsEditing(!isEditing)} 264 + title="Edit" 265 + > 266 + <Edit2 size={16} /> 267 + </button> 268 + )} 250 269 <button 251 270 className="annotation-action action-icon-only" 252 - onClick={fetchHistory} 253 - title="View Edit History" 271 + onClick={handleDelete} 272 + disabled={deleting} 273 + title="Delete" 254 274 > 255 - <Clock size={16} /> 275 + <Trash2 size={16} /> 256 276 </button> 257 - )} 258 - 259 - {isOwner && !(data.uri && data.uri.includes("network.cosmik")) && ( 260 - <> 261 - {!data.color && !data.description && ( 262 - <button 263 - className="annotation-action action-icon-only" 264 - onClick={() => setIsEditing(!isEditing)} 265 - title="Edit" 266 - > 267 - <Edit2 size={16} /> 268 - </button> 269 - )} 270 - <button 271 - className="annotation-action action-icon-only" 272 - onClick={handleDelete} 273 - disabled={deleting} 274 - title="Delete" 275 - > 276 - <Trash2 size={16} /> 277 - </button> 278 - </> 279 - )} 280 - </div> 277 + </> 278 + )} 281 279 </div> 282 280 </header> 283 281 ··· 286 284 <div className="history-header"> 287 285 <h4 className="history-title">Edit History</h4> 288 286 <button 289 - className="history-close-btn" 287 + className="annotation-action action-icon-only" 290 288 onClick={() => setShowHistory(false)} 291 - title="Close History" 292 289 > 293 290 <X size={14} /> 294 291 </button> ··· 321 318 > 322 319 {truncateUrl(data.url)} 323 320 {data.title && ( 324 - <span className="annotation-source-title"> • {data.title}</span> 321 + <span className="annotation-source-title"> · {data.title}</span> 325 322 )} 326 323 </a> 327 324 ··· 331 328 target="_blank" 332 329 rel="noopener noreferrer" 333 330 className="annotation-highlight" 334 - style={{ 335 - borderLeftColor: data.color || "var(--accent)", 336 - }} 331 + style={{ borderLeftColor: data.color || "var(--accent)" }} 337 332 > 338 - <mark>&quot;{highlightedText}&quot;</mark> 333 + <mark>&ldquo;{highlightedText}&rdquo;</mark> 339 334 </a> 340 335 )} 341 336 342 337 {isEditing ? ( 343 - <div className="mt-3"> 338 + <div className="edit-form"> 344 339 <textarea 345 340 value={editText} 346 341 onChange={(e) => setEditText(e.target.value)} 347 342 className="reply-input" 348 343 rows={3} 349 - style={{ marginBottom: "8px" }} 344 + placeholder="Your annotation..." 350 345 /> 351 346 <input 352 347 type="text" ··· 354 349 placeholder="Tags (comma separated)..." 355 350 value={editTags} 356 351 onChange={(e) => setEditTags(e.target.value)} 357 - style={{ marginBottom: "8px" }} 352 + style={{ marginTop: "8px" }} 358 353 /> 359 - <div className="action-buttons-end"> 354 + <div className="action-buttons-end" style={{ marginTop: "8px" }}> 360 355 <button 361 356 onClick={() => setIsEditing(false)} 362 357 className="btn btn-ghost" ··· 366 361 <button 367 362 onClick={handleSaveEdit} 368 363 disabled={saving} 369 - className="btn btn-primary btn-sm" 364 + className="btn btn-primary" 370 365 > 371 366 {saving ? ( 372 367 "Saving..." ··· 403 398 className={`annotation-action ${isLiked ? "liked" : ""}`} 404 399 onClick={handleLike} 405 400 > 406 - <Heart filled={isLiked} size={16} /> 401 + <Heart size={16} fill={isLiked ? "currentColor" : "none"} /> 407 402 {likeCount > 0 && <span>{likeCount}</span>} 408 403 </button> 404 + 409 405 <button 410 406 className={`annotation-action ${showReplies ? "active" : ""}`} 411 - onClick={async () => { 412 - if (!showReplies && replies.length === 0) { 413 - try { 414 - const res = await getReplies(data.uri); 415 - if (res.items) setReplies(res.items); 416 - } catch (err) { 417 - console.error("Failed to load replies:", err); 418 - } 419 - } 420 - setShowReplies(!showReplies); 421 - }} 407 + onClick={loadReplies} 422 408 > 423 409 <MessageSquare size={16} /> 424 - <span>{replyCount > 0 ? `${replyCount}` : "Reply"}</span> 410 + <span>{replyCount > 0 ? replyCount : "Reply"}</span> 425 411 </button> 412 + 426 413 <ShareMenu 427 414 uri={data.uri} 428 415 text={data.title || data.url} ··· 430 417 type="Annotation" 431 418 url={data.url} 432 419 /> 433 - <button 434 - className="annotation-action" 435 - onClick={() => { 436 - if (!user) { 437 - login(); 438 - return; 439 - } 440 - if (onAddToCollection) onAddToCollection(); 441 - }} 442 - > 420 + 421 + <button className="annotation-action" onClick={handleCollect}> 443 422 <Folder size={16} /> 444 423 <span>Collect</span> 445 424 </button> ··· 471 450 472 451 <div className="reply-form"> 473 452 {replyingTo && ( 474 - <div 475 - style={{ 476 - display: "flex", 477 - alignItems: "center", 478 - gap: "8px", 479 - marginBottom: "8px", 480 - fontSize: "0.85rem", 481 - color: "var(--text-secondary)", 482 - }} 483 - > 453 + <div className="replying-to-banner"> 484 454 <span> 485 455 Replying to @ 486 456 {(replyingTo.creator || replyingTo.author)?.handle || ··· 488 458 </span> 489 459 <button 490 460 onClick={() => setReplyingTo(null)} 491 - style={{ 492 - background: "none", 493 - border: "none", 494 - color: "var(--text-tertiary)", 495 - cursor: "pointer", 496 - padding: "2px 6px", 497 - }} 461 + className="cancel-reply" 498 462 > 499 463 × 500 464 </button> ··· 509 473 } 510 474 value={replyText} 511 475 onChange={(e) => setReplyText(e.target.value)} 512 - onFocus={(e) => { 513 - if (!user) { 514 - e.preventDefault(); 515 - alert("Please sign in to like annotations"); 516 - } 517 - }} 518 476 rows={2} 519 477 /> 520 - <div className="action-buttons-end"> 478 + <div className="reply-form-actions"> 521 479 <button 522 - className="btn btn-primary btn-sm" 480 + className="btn btn-primary" 523 481 disabled={posting || !replyText.trim()} 524 482 onClick={() => { 525 483 if (!user) { ··· 551 509 data.selector?.type === "TextQuoteSelector" ? data.selector.exact : null; 552 510 const fragmentUrl = buildTextFragmentUrl(data.url, data.selector); 553 511 const isOwner = user?.did && data.author?.did === user.did; 512 + const isSemble = data.uri?.includes("network.cosmik"); 513 + 554 514 const [isEditing, setIsEditing] = useState(false); 555 515 const [editColor, setEditColor] = useState(data.color || "#f59e0b"); 556 516 const [editTags, setEditTags] = useState(data.tags?.join(", ") || ""); ··· 561 521 .split(",") 562 522 .map((t) => t.trim()) 563 523 .filter(Boolean); 564 - 565 524 await updateHighlight(data.uri, editColor, tagList); 566 525 setIsEditing(false); 567 - if (typeof onUpdate === "function") 526 + if (typeof onUpdate === "function") { 568 527 onUpdate({ ...highlight, color: editColor, tags: tagList }); 528 + } 569 529 } catch (err) { 570 530 alert("Failed to update: " + err.message); 571 531 } 572 532 }; 573 533 534 + const handleCollect = () => { 535 + if (!user) { 536 + login(); 537 + return; 538 + } 539 + if (onAddToCollection) onAddToCollection(); 540 + }; 541 + 574 542 return ( 575 543 <article className="card annotation-card"> 576 544 <header className="annotation-header"> 577 545 <div className="annotation-header-left"> 578 546 <UserMeta author={data.author} createdAt={data.createdAt} /> 579 547 </div> 580 - 581 548 <div className="annotation-header-right"> 582 - <div style={{ display: "flex", gap: "4px", alignItems: "center" }}> 583 - {data.uri && data.uri.includes("network.cosmik") && ( 584 - <div 585 - style={{ 586 - display: "flex", 587 - alignItems: "center", 588 - gap: "4px", 589 - fontSize: "0.75rem", 590 - color: "var(--text-tertiary)", 591 - marginRight: "8px", 549 + {isSemble && ( 550 + <div className="semble-badge" title="Added using Semble"> 551 + <span>via Semble</span> 552 + <img src="/semble-logo.svg" alt="Semble" /> 553 + </div> 554 + )} 555 + {isOwner && ( 556 + <> 557 + <button 558 + className="annotation-action action-icon-only" 559 + onClick={() => setIsEditing(!isEditing)} 560 + title="Edit Color" 561 + > 562 + <Edit2 size={16} /> 563 + </button> 564 + <button 565 + className="annotation-action action-icon-only" 566 + onClick={(e) => { 567 + e.preventDefault(); 568 + onDelete && onDelete(highlight.id || highlight.uri); 592 569 }} 593 - title="Added using Semble" 570 + title="Delete" 594 571 > 595 - <span>via Semble</span> 596 - <img 597 - src="/semble-logo.svg" 598 - alt="Semble" 599 - style={{ width: "16px", height: "16px" }} 600 - /> 601 - </div> 602 - )} 603 - {isOwner && ( 604 - <> 605 - <button 606 - className="annotation-action action-icon-only" 607 - onClick={() => setIsEditing(!isEditing)} 608 - title="Edit Color" 609 - > 610 - <Edit2 size={16} /> 611 - </button> 612 - <button 613 - className="annotation-action action-icon-only" 614 - onClick={(e) => { 615 - e.preventDefault(); 616 - onDelete && onDelete(highlight.id || highlight.uri); 617 - }} 618 - > 619 - <TrashIcon size={16} /> 620 - </button> 621 - </> 622 - )} 623 - </div> 572 + <TrashIcon size={16} /> 573 + </button> 574 + </> 575 + )} 624 576 </div> 625 577 </header> 626 578 ··· 644 596 borderLeftColor: isEditing ? editColor : data.color || "#f59e0b", 645 597 }} 646 598 > 647 - <mark>&quot;{highlightedText}&quot;</mark> 599 + <mark>&ldquo;{highlightedText}&rdquo;</mark> 648 600 </a> 649 601 )} 650 602 651 603 {isEditing && ( 652 - <div 653 - className="mt-3" 654 - style={{ 655 - display: "flex", 656 - gap: "8px", 657 - alignItems: "center", 658 - padding: "8px", 659 - background: "var(--bg-secondary)", 660 - borderRadius: "var(--radius-md)", 661 - border: "1px solid var(--border)", 662 - }} 663 - > 664 - <div 665 - className="color-picker-compact" 666 - style={{ 667 - position: "relative", 668 - width: "28px", 669 - height: "28px", 670 - flexShrink: 0, 671 - }} 672 - > 604 + <div className="color-edit-form"> 605 + <div className="color-picker-wrapper"> 673 606 <div 674 - style={{ 675 - backgroundColor: editColor, 676 - width: "100%", 677 - height: "100%", 678 - borderRadius: "50%", 679 - border: "2px solid var(--bg-card)", 680 - boxShadow: "0 0 0 1px var(--border)", 681 - }} 607 + className="color-preview" 608 + style={{ backgroundColor: editColor }} 682 609 /> 683 610 <input 684 611 type="color" 685 612 value={editColor} 686 613 onChange={(e) => setEditColor(e.target.value)} 687 - style={{ 688 - position: "absolute", 689 - top: 0, 690 - left: 0, 691 - width: "100%", 692 - height: "100%", 693 - opacity: 0, 694 - cursor: "pointer", 695 - }} 696 - title="Change Color" 614 + className="color-input" 697 615 /> 698 616 </div> 699 - 700 617 <input 701 618 type="text" 702 619 className="reply-input" 703 - placeholder="e.g. tag1, tag2" 620 + placeholder="Tags (comma separated)" 704 621 value={editTags} 705 622 onChange={(e) => setEditTags(e.target.value)} 706 - style={{ 707 - margin: 0, 708 - flex: 1, 709 - fontSize: "0.9rem", 710 - padding: "6px 10px", 711 - height: "32px", 712 - border: "none", 713 - background: "transparent", 714 - }} 623 + style={{ flex: 1, margin: 0 }} 715 624 /> 716 - 717 625 <button 718 626 onClick={handleSaveEdit} 719 - className="btn btn-primary btn-sm" 720 - style={{ padding: "0 10px", height: "32px", minWidth: "auto" }} 721 - title="Save" 627 + className="btn btn-primary" 628 + style={{ padding: "0 12px", height: "32px" }} 722 629 > 723 630 <Save size={16} /> 724 631 </button> ··· 744 651 <div className="annotation-actions-left"> 745 652 <span 746 653 className="annotation-action" 747 - style={{ 748 - color: data.color || "#f59e0b", 749 - background: "none", 750 - paddingLeft: 0, 751 - }} 654 + style={{ color: data.color || "#f59e0b", cursor: "default" }} 752 655 > 753 656 <HighlightIcon size={14} /> Highlight 754 657 </span> 658 + 755 659 <ShareMenu 756 660 uri={data.uri} 757 661 text={data.title || data.description} 758 662 handle={data.author?.handle} 759 663 type="Highlight" 760 664 /> 761 - <button 762 - className="annotation-action" 763 - onClick={() => { 764 - if (!user) { 765 - login(); 766 - return; 767 - } 768 - if (onAddToCollection) onAddToCollection(); 769 - }} 770 - > 665 + 666 + <button className="annotation-action" onClick={handleCollect}> 771 667 <Folder size={16} /> 772 668 <span>Collect</span> 773 669 </button>
+36 -56
web/src/components/BookmarkCard.jsx
··· 8 8 getLikeCount, 9 9 deleteBookmark, 10 10 } from "../api/client"; 11 - import { HeartIcon, TrashIcon, BookmarkIcon } from "./Icons"; 12 - import { Folder } from "lucide-react"; 11 + import { HeartIcon, TrashIcon } from "./Icons"; 12 + import { Folder, ExternalLink } from "lucide-react"; 13 13 import ShareMenu from "./ShareMenu"; 14 14 import UserMeta from "./UserMeta"; 15 15 ··· 28 28 const [deleting, setDeleting] = useState(false); 29 29 30 30 const isOwner = user?.did && data.author?.did === user.did; 31 + const isSemble = data.uri?.includes("network.cosmik"); 32 + 33 + let domain = ""; 34 + try { 35 + if (data.url) domain = new URL(data.url).hostname.replace("www.", ""); 36 + } catch { 37 + /* ignore */ 38 + } 31 39 32 40 useEffect(() => { 33 41 let mounted = true; ··· 75 83 onDelete(data.uri); 76 84 return; 77 85 } 78 - 79 86 if (!confirm("Delete this bookmark?")) return; 80 87 try { 81 88 setDeleting(true); ··· 90 97 } 91 98 }; 92 99 93 - let domain = ""; 94 - try { 95 - if (data.url) domain = new URL(data.url).hostname.replace("www.", ""); 96 - } catch { 97 - /* ignore */ 98 - } 100 + const handleCollect = () => { 101 + if (!user) { 102 + login(); 103 + return; 104 + } 105 + if (onAddToCollection) onAddToCollection(); 106 + }; 99 107 100 108 return ( 101 109 <article className="card annotation-card bookmark-card"> ··· 103 111 <div className="annotation-header-left"> 104 112 <UserMeta author={data.author} createdAt={data.createdAt} /> 105 113 </div> 106 - 107 114 <div className="annotation-header-right"> 108 - <div style={{ display: "flex", gap: "4px", alignItems: "center" }}> 109 - {data.uri && data.uri.includes("network.cosmik") && ( 110 - <div 111 - style={{ 112 - display: "flex", 113 - alignItems: "center", 114 - gap: "4px", 115 - fontSize: "0.75rem", 116 - color: "var(--text-tertiary)", 117 - marginRight: "8px", 118 - }} 119 - title="Added using Semble" 120 - > 121 - <span>via Semble</span> 122 - <img 123 - src="/semble-logo.svg" 124 - alt="Semble" 125 - style={{ width: "16px", height: "16px" }} 126 - /> 127 - </div> 128 - )} 129 - <div style={{ display: "flex", gap: "4px" }}> 130 - {((isOwner && 131 - !(data.uri && data.uri.includes("network.cosmik"))) || 132 - onDelete) && ( 133 - <button 134 - className="annotation-action action-icon-only" 135 - onClick={handleDelete} 136 - disabled={deleting} 137 - title="Delete" 138 - > 139 - <TrashIcon size={16} /> 140 - </button> 141 - )} 115 + {isSemble && ( 116 + <div className="semble-badge" title="Added using Semble"> 117 + <span>via Semble</span> 118 + <img src="/semble-logo.svg" alt="Semble" /> 142 119 </div> 143 - </div> 120 + )} 121 + {((isOwner && !isSemble) || onDelete) && ( 122 + <button 123 + className="annotation-action action-icon-only" 124 + onClick={handleDelete} 125 + disabled={deleting} 126 + title="Delete" 127 + > 128 + <TrashIcon size={16} /> 129 + </button> 130 + )} 144 131 </div> 145 132 </header> 146 133 ··· 153 140 > 154 141 <div className="bookmark-preview-content"> 155 142 <div className="bookmark-preview-site"> 156 - <BookmarkIcon size={14} /> 143 + <ExternalLink size={12} /> 157 144 <span>{domain}</span> 158 145 </div> 159 146 <h3 className="bookmark-preview-title">{data.title || data.url}</h3> ··· 183 170 <HeartIcon filled={isLiked} size={16} /> 184 171 {likeCount > 0 && <span>{likeCount}</span>} 185 172 </button> 173 + 186 174 <ShareMenu 187 175 uri={data.uri} 188 176 text={data.title || data.description} ··· 190 178 type="Bookmark" 191 179 url={data.url} 192 180 /> 193 - <button 194 - className="annotation-action" 195 - onClick={() => { 196 - if (!user) { 197 - login(); 198 - return; 199 - } 200 - if (onAddToCollection) onAddToCollection(); 201 - }} 202 - > 181 + 182 + <button className="annotation-action" onClick={handleCollect}> 203 183 <Folder size={16} /> 204 184 <span>Collect</span> 205 185 </button>
+55 -70
web/src/components/CollectionItemCard.jsx
··· 5 5 import CollectionIcon from "./CollectionIcon"; 6 6 import ShareMenu from "./ShareMenu"; 7 7 8 - export default function CollectionItemCard({ item }) { 8 + export default function CollectionItemCard({ item, onAddToCollection }) { 9 9 const author = item.creator; 10 10 const collection = item.collection; 11 11 12 12 if (!author || !collection) return null; 13 13 14 - let inner = null; 15 - if (item.annotation) { 16 - inner = <AnnotationCard annotation={item.annotation} />; 17 - } else if (item.highlight) { 18 - inner = <HighlightCard highlight={item.highlight} />; 19 - } else if (item.bookmark) { 20 - inner = <BookmarkCard bookmark={item.bookmark} />; 21 - } 14 + const innerItem = item.annotation || item.highlight || item.bookmark; 15 + if (!innerItem) return null; 22 16 23 - if (!inner) return null; 17 + const innerUri = innerItem.uri || innerItem.id; 24 18 25 19 return ( 26 - <div className="collection-feed-item" style={{ marginBottom: "20px" }}> 27 - <div 28 - className="feed-context-header" 29 - style={{ 30 - display: "flex", 31 - alignItems: "center", 32 - gap: "8px", 33 - marginBottom: "8px", 34 - fontSize: "14px", 35 - color: "var(--text-secondary)", 36 - }} 37 - > 38 - {author.avatar && ( 39 - <img 40 - src={author.avatar} 41 - alt={author.handle} 42 - style={{ 43 - width: "24px", 44 - height: "24px", 45 - borderRadius: "50%", 46 - objectFit: "cover", 47 - }} 48 - /> 49 - )} 50 - <span> 51 - <span style={{ fontWeight: 600, color: "var(--text-primary)" }}> 52 - {author.displayName || author.handle} 53 - </span>{" "} 54 - added to{" "} 55 - <Link 56 - to={`/${author.handle}/collection/${collection.uri.split("/").pop()}`} 57 - style={{ 58 - display: "inline-flex", 59 - alignItems: "center", 60 - gap: "4px", 61 - fontWeight: 500, 62 - color: "var(--primary)", 63 - textDecoration: "none", 64 - }} 65 - > 66 - <CollectionIcon icon={collection.icon} size={14} /> 67 - {collection.name} 68 - </Link> 69 - </span> 70 - <div style={{ marginLeft: "auto" }}> 71 - <ShareMenu 72 - uri={collection.uri} 73 - handle={author.handle} 74 - type="Collection" 75 - text={`Check out this collection by ${author.displayName}: ${collection.name}`} 76 - /> 20 + <div className="collection-feed-item"> 21 + <div className="collection-context-badge"> 22 + <div className="collection-context-inner"> 23 + {author.avatar && ( 24 + <img 25 + src={author.avatar} 26 + alt={author.handle} 27 + className="collection-context-avatar" 28 + /> 29 + )} 30 + <span className="collection-context-text"> 31 + <Link 32 + to={`/profile/${author.did}`} 33 + className="collection-context-author" 34 + > 35 + {author.displayName || author.handle} 36 + </Link>{" "} 37 + added to{" "} 38 + <Link 39 + to={`/${author.handle}/collection/${collection.uri.split("/").pop()}`} 40 + className="collection-context-link" 41 + > 42 + <CollectionIcon icon={collection.icon} size={14} /> 43 + {collection.name} 44 + </Link> 45 + </span> 77 46 </div> 78 - </div> 79 - <div 80 - className="feed-context-body" 81 - style={{ 82 - paddingLeft: "16px", 83 - borderLeft: "2px solid var(--border-color)", 84 - }} 85 - > 86 - {inner} 47 + <ShareMenu 48 + uri={collection.uri} 49 + handle={author.handle} 50 + type="Collection" 51 + text={`Check out this collection: ${collection.name}`} 52 + /> 87 53 </div> 54 + 55 + {item.annotation && ( 56 + <AnnotationCard 57 + annotation={item.annotation} 58 + onAddToCollection={() => onAddToCollection?.(innerUri)} 59 + /> 60 + )} 61 + {item.highlight && ( 62 + <HighlightCard 63 + highlight={item.highlight} 64 + onAddToCollection={() => onAddToCollection?.(innerUri)} 65 + /> 66 + )} 67 + {item.bookmark && ( 68 + <BookmarkCard 69 + bookmark={item.bookmark} 70 + onAddToCollection={() => onAddToCollection?.(innerUri)} 71 + /> 72 + )} 88 73 </div> 89 74 ); 90 75 }
+47 -1
web/src/components/CollectionModal.jsx
··· 41 41 Moon, 42 42 Flame, 43 43 Leaf, 44 + Trash2, 44 45 } from "lucide-react"; 45 - import { createCollection, updateCollection } from "../api/client"; 46 + import { 47 + createCollection, 48 + updateCollection, 49 + deleteCollection, 50 + } from "../api/client"; 46 51 47 52 const EMOJI_OPTIONS = [ 48 53 "📁", ··· 125 130 onClose, 126 131 onSuccess, 127 132 collectionToEdit, 133 + onDelete, 128 134 }) { 129 135 const [name, setName] = useState(""); 130 136 const [description, setDescription] = useState(""); ··· 132 138 const [customEmoji, setCustomEmoji] = useState(""); 133 139 const [activeTab, setActiveTab] = useState("emoji"); 134 140 const [loading, setLoading] = useState(false); 141 + const [deleting, setDeleting] = useState(false); 135 142 const [error, setError] = useState(null); 136 143 137 144 useEffect(() => { ··· 211 218 } 212 219 }; 213 220 221 + const handleDelete = async () => { 222 + if ( 223 + !confirm( 224 + "Delete this collection and all its items? This cannot be undone.", 225 + ) 226 + ) { 227 + return; 228 + } 229 + setDeleting(true); 230 + setError(null); 231 + 232 + try { 233 + await deleteCollection(collectionToEdit.uri); 234 + if (onDelete) { 235 + onDelete(); 236 + } else { 237 + onSuccess(); 238 + } 239 + onClose(); 240 + } catch (err) { 241 + console.error(err); 242 + setError(err.message || "Failed to delete collection"); 243 + } finally { 244 + setDeleting(false); 245 + } 246 + }; 247 + 214 248 return ( 215 249 <div className="modal-overlay" onClick={onClose}> 216 250 <div ··· 327 361 </div> 328 362 329 363 <div className="modal-actions"> 364 + {collectionToEdit && ( 365 + <button 366 + type="button" 367 + onClick={handleDelete} 368 + disabled={deleting} 369 + className="btn btn-danger" 370 + > 371 + <Trash2 size={16} /> 372 + {deleting ? "Deleting..." : "Delete"} 373 + </button> 374 + )} 375 + <div style={{ flex: 1 }} /> 330 376 <button type="button" onClick={onClose} className="btn btn-ghost"> 331 377 Cancel 332 378 </button>
+52
web/src/components/IOSInstallBanner.jsx
··· 1 + import { useState } from "react"; 2 + import { X } from "lucide-react"; 3 + import { SiApple } from "react-icons/si"; 4 + 5 + function shouldShowBanner() { 6 + if (typeof window === "undefined") return false; 7 + const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent); 8 + if (!isIOS) return false; 9 + 10 + const dismissedAt = localStorage.getItem("ios-shortcut-dismissed"); 11 + const daysSinceDismissed = dismissedAt 12 + ? (Date.now() - parseInt(dismissedAt, 10)) / (1000 * 60 * 60 * 24) 13 + : Infinity; 14 + return daysSinceDismissed > 7; 15 + } 16 + 17 + export default function IOSInstallBanner() { 18 + const [show, setShow] = useState(shouldShowBanner); 19 + 20 + const handleDismiss = () => { 21 + setShow(false); 22 + localStorage.setItem("ios-shortcut-dismissed", Date.now().toString()); 23 + }; 24 + 25 + if (!show) return null; 26 + 27 + return ( 28 + <div className="ios-shortcut-banner"> 29 + <button 30 + className="ios-shortcut-banner-close" 31 + onClick={handleDismiss} 32 + aria-label="Dismiss" 33 + > 34 + <X size={14} /> 35 + </button> 36 + <div className="ios-shortcut-banner-content"> 37 + <div className="ios-shortcut-banner-text"> 38 + <p>Save pages directly from Safari</p> 39 + </div> 40 + <a 41 + href="https://www.icloud.com/shortcuts/21c87edf29b046db892c9e57dac6d1fd" 42 + target="_blank" 43 + rel="noopener noreferrer" 44 + className="ios-shortcut-banner-btn" 45 + > 46 + <SiApple size={14} /> 47 + Get iOS Shortcut 48 + </a> 49 + </div> 50 + </div> 51 + ); 52 + }
+68 -39
web/src/components/MobileNav.jsx
··· 1 1 import { Link, useLocation } from "react-router-dom"; 2 2 import { useAuth } from "../context/AuthContext"; 3 - import { Home, Search, Folder, User, PenSquare } from "lucide-react"; 3 + import { Home, Search, Folder, User, PenSquare, Bookmark } from "lucide-react"; 4 4 5 5 export default function MobileNav() { 6 6 const { user, isAuthenticated } = useAuth(); ··· 12 12 }; 13 13 14 14 return ( 15 - <nav className="mobile-nav"> 16 - <div className="mobile-nav-inner"> 17 - <Link 18 - to="/" 19 - className={`mobile-nav-item ${isActive("/") ? "active" : ""}`} 20 - > 21 - <Home /> 22 - <span>Home</span> 23 - </Link> 15 + <nav className="mobile-bottom-nav"> 16 + <Link 17 + to="/" 18 + className={`mobile-bottom-nav-item ${isActive("/") ? "active" : ""}`} 19 + > 20 + <Home size={22} /> 21 + <span>Home</span> 22 + </Link> 24 23 25 - <Link 26 - to="/url" 27 - className={`mobile-nav-item ${isActive("/url") ? "active" : ""}`} 28 - > 29 - <Search /> 30 - <span>Browse</span> 31 - </Link> 24 + <Link 25 + to="/url" 26 + className={`mobile-bottom-nav-item ${isActive("/url") ? "active" : ""}`} 27 + > 28 + <Search size={22} /> 29 + <span>Browse</span> 30 + </Link> 32 31 33 - {isAuthenticated ? ( 34 - <Link to="/new" className="mobile-nav-item mobile-nav-new"> 35 - <PenSquare /> 32 + {isAuthenticated ? ( 33 + <> 34 + <Link 35 + to="/new" 36 + className="mobile-bottom-nav-item mobile-bottom-nav-new" 37 + > 38 + <div className="mobile-nav-new-btn"> 39 + <PenSquare size={20} /> 40 + </div> 41 + </Link> 42 + 43 + <Link 44 + to="/bookmarks" 45 + className={`mobile-bottom-nav-item ${isActive("/bookmarks") || isActive("/collections") ? "active" : ""}`} 46 + > 47 + <Bookmark size={22} /> 48 + <span>Library</span> 49 + </Link> 50 + 51 + <Link 52 + to={user?.did ? `/profile/${user.did}` : "/profile"} 53 + className={`mobile-bottom-nav-item ${isActive("/profile") ? "active" : ""}`} 54 + > 55 + {user?.avatar ? ( 56 + <img src={user.avatar} alt="" className="mobile-nav-avatar" /> 57 + ) : ( 58 + <User size={22} /> 59 + )} 60 + <span>You</span> 36 61 </Link> 37 - ) : ( 38 - <Link to="/login" className="mobile-nav-item mobile-nav-new"> 39 - <User /> 62 + </> 63 + ) : ( 64 + <> 65 + <Link 66 + to="/login" 67 + className="mobile-bottom-nav-item mobile-bottom-nav-new" 68 + > 69 + <div className="mobile-nav-new-btn"> 70 + <User size={20} /> 71 + </div> 40 72 </Link> 41 - )} 42 73 43 - <Link 44 - to="/collections" 45 - className={`mobile-nav-item ${isActive("/collections") ? "active" : ""}`} 46 - > 47 - <Folder /> 48 - <span>Library</span> 49 - </Link> 74 + <Link 75 + to="/collections" 76 + className={`mobile-bottom-nav-item ${isActive("/collections") ? "active" : ""}`} 77 + > 78 + <Folder size={22} /> 79 + <span>Library</span> 80 + </Link> 50 81 51 - <Link 52 - to={isAuthenticated && user?.did ? `/profile/${user.did}` : "/login"} 53 - className={`mobile-nav-item ${isActive("/profile") ? "active" : ""}`} 54 - > 55 - <User /> 56 - <span>Profile</span> 57 - </Link> 58 - </div> 82 + <Link to="/login" className={`mobile-bottom-nav-item`}> 83 + <User size={22} /> 84 + <span>Sign In</span> 85 + </Link> 86 + </> 87 + )} 59 88 </nav> 60 89 ); 61 90 }
-226
web/src/components/RightSidebar.jsx
··· 1 - import { useState, useEffect } from "react"; 2 - import { Link } from "react-router-dom"; 3 - import { ExternalLink, Sun, Moon, Monitor } from "lucide-react"; 4 - import { 5 - SiFirefox, 6 - SiGooglechrome, 7 - SiGithub, 8 - SiBluesky, 9 - SiApple, 10 - SiKofi, 11 - SiDiscord, 12 - } from "react-icons/si"; 13 - import { FaEdge } from "react-icons/fa"; 14 - import { useAuth } from "../context/AuthContext"; 15 - import { useTheme } from "../context/ThemeContext"; 16 - import { getTrendingTags } from "../api/client"; 17 - 18 - const isFirefox = 19 - typeof navigator !== "undefined" && /Firefox/i.test(navigator.userAgent); 20 - const isEdge = 21 - typeof navigator !== "undefined" && /Edg/i.test(navigator.userAgent); 22 - const isMobileSafari = 23 - typeof navigator !== "undefined" && 24 - /iPhone|iPad|iPod/.test(navigator.userAgent) && 25 - /Safari/.test(navigator.userAgent) && 26 - !/CriOS|FxiOS|OPiOS|EdgiOS/.test(navigator.userAgent); 27 - 28 - function getExtensionInfo() { 29 - if (isMobileSafari) { 30 - return { 31 - url: "https://margin.at/soon", 32 - icon: SiApple, 33 - name: "iOS", 34 - label: "Coming Soon", 35 - }; 36 - } 37 - if (isFirefox) { 38 - return { 39 - url: "https://addons.mozilla.org/en-US/firefox/addon/margin/", 40 - icon: SiFirefox, 41 - name: "Firefox", 42 - label: "Install for Firefox", 43 - }; 44 - } 45 - if (isEdge) { 46 - return { 47 - url: "https://microsoftedge.microsoft.com/addons/detail/margin/nfjnmllpdgcdnhmmggjihjbidmeadddn", 48 - icon: FaEdge, 49 - name: "Edge", 50 - label: "Install for Edge", 51 - }; 52 - } 53 - return { 54 - url: "https://chromewebstore.google.com/detail/margin/cgpmbiiagnehkikhcbnhiagfomajncpa/", 55 - icon: SiGooglechrome, 56 - name: "Chrome", 57 - label: "Install for Chrome", 58 - }; 59 - } 60 - 61 - export default function RightSidebar() { 62 - const { theme, setTheme } = useTheme(); 63 - const { isAuthenticated } = useAuth(); 64 - const ext = getExtensionInfo(); 65 - const ExtIcon = ext.icon; 66 - const [trendingTags, setTrendingTags] = useState([]); 67 - const [loading, setLoading] = useState(true); 68 - 69 - useEffect(() => { 70 - getTrendingTags() 71 - .then((tags) => setTrendingTags(tags)) 72 - .catch((err) => console.error("Failed to fetch trending tags:", err)) 73 - .finally(() => setLoading(false)); 74 - }, []); 75 - 76 - return ( 77 - <aside className="right-sidebar"> 78 - <div className="right-section"> 79 - <h3 className="right-section-title"> 80 - {isMobileSafari ? "Save from Safari" : "Get the Extension"} 81 - </h3> 82 - <p className="right-section-desc"> 83 - {isMobileSafari 84 - ? "Bookmark pages using Safari's share sheet" 85 - : "Annotate, highlight, and bookmark any webpage"} 86 - </p> 87 - <a 88 - href={ext.url} 89 - target="_blank" 90 - rel="noopener noreferrer" 91 - className="right-extension-btn" 92 - > 93 - <ExtIcon size={18} /> 94 - {ext.label} 95 - <ExternalLink size={14} /> 96 - </a> 97 - </div> 98 - 99 - {isAuthenticated ? ( 100 - <div className="right-section"> 101 - <h3 className="right-section-title">Trending Tags</h3> 102 - <div className="right-links"> 103 - {loading ? ( 104 - <span className="right-section-desc">Loading...</span> 105 - ) : trendingTags.length > 0 ? ( 106 - trendingTags.map(({ tag, count }) => ( 107 - <Link 108 - key={tag} 109 - to={`/?tag=${encodeURIComponent(tag)}`} 110 - className="right-link" 111 - > 112 - <span>#{tag}</span> 113 - <span style={{ fontSize: "0.75rem", opacity: 0.6 }}> 114 - {count} 115 - </span> 116 - </Link> 117 - )) 118 - ) : ( 119 - <span className="right-section-desc">No trending tags yet</span> 120 - )} 121 - </div> 122 - </div> 123 - ) : ( 124 - <div className="right-section"> 125 - <h3 className="right-section-title">Explore</h3> 126 - <nav className="right-links"> 127 - <Link to="/url" className="right-link"> 128 - Browse by URL 129 - </Link> 130 - </nav> 131 - </div> 132 - )} 133 - 134 - <div className="right-section"> 135 - <h3 className="right-section-title">Resources</h3> 136 - <nav className="right-links"> 137 - <a 138 - href="https://github.com/margin-at/margin" 139 - target="_blank" 140 - rel="noopener noreferrer" 141 - className="right-link" 142 - > 143 - <div style={{ display: "flex", alignItems: "center", gap: "8px" }}> 144 - <SiGithub size={16} /> 145 - GitHub 146 - </div> 147 - <ExternalLink size={12} /> 148 - </a> 149 - <a 150 - href="https://tangled.org/margin.at/margin" 151 - target="_blank" 152 - rel="noopener noreferrer" 153 - className="right-link" 154 - > 155 - <div style={{ display: "flex", alignItems: "center", gap: "8px" }}> 156 - <div className="tangled-icon" /> 157 - Tangled 158 - </div> 159 - <ExternalLink size={12} /> 160 - </a> 161 - <a 162 - href="https://bsky.app/profile/margin.at" 163 - target="_blank" 164 - rel="noopener noreferrer" 165 - className="right-link" 166 - > 167 - <div style={{ display: "flex", alignItems: "center", gap: "8px" }}> 168 - <SiBluesky size={16} /> 169 - Bluesky 170 - </div> 171 - <ExternalLink size={12} /> 172 - </a> 173 - <a 174 - href="https://discord.gg/ZQbkGqwzBH" 175 - target="_blank" 176 - rel="noopener noreferrer" 177 - className="right-link" 178 - > 179 - <div style={{ display: "flex", alignItems: "center", gap: "8px" }}> 180 - <SiDiscord size={16} /> 181 - Discord 182 - </div> 183 - <ExternalLink size={12} /> 184 - </a> 185 - <a 186 - href="https://ko-fi.com/scan" 187 - target="_blank" 188 - rel="noopener noreferrer" 189 - className="right-link" 190 - > 191 - <div style={{ display: "flex", alignItems: "center", gap: "8px" }}> 192 - <SiKofi size={16} /> 193 - Donate 194 - </div> 195 - <ExternalLink size={12} /> 196 - </a> 197 - </nav> 198 - </div> 199 - 200 - <div className="right-footer"> 201 - <div className="footer-links"> 202 - <Link to="/privacy">Privacy</Link> 203 - <span>·</span> 204 - <Link to="/terms">Terms</Link> 205 - </div> 206 - <button 207 - onClick={() => { 208 - const next = 209 - theme === "system" 210 - ? "light" 211 - : theme === "light" 212 - ? "dark" 213 - : "system"; 214 - setTheme(next); 215 - }} 216 - className="theme-toggle-mini" 217 - title={`Theme: ${theme}`} 218 - > 219 - {theme === "system" && <Monitor size={14} />} 220 - {theme === "light" && <Sun size={14} />} 221 - {theme === "dark" && <Moon size={14} />} 222 - </button> 223 - </div> 224 - </aside> 225 - ); 226 - }
-189
web/src/components/Sidebar.jsx
··· 1 - import { useState, useRef, useEffect } from "react"; 2 - import { Link, useLocation } from "react-router-dom"; 3 - import { useAuth } from "../context/AuthContext"; 4 - import { 5 - Home, 6 - Search, 7 - Folder, 8 - Bell, 9 - PenSquare, 10 - User, 11 - LogOut, 12 - MoreHorizontal, 13 - Highlighter, 14 - Bookmark, 15 - } from "lucide-react"; 16 - import { getUnreadNotificationCount } from "../api/client"; 17 - import logo from "../assets/logo.svg"; 18 - 19 - export default function Sidebar() { 20 - const { user, isAuthenticated, logout, loading } = useAuth(); 21 - const location = useLocation(); 22 - const [menuOpen, setMenuOpen] = useState(false); 23 - const [unreadCount, setUnreadCount] = useState(0); 24 - const menuRef = useRef(null); 25 - 26 - const isActive = (path) => { 27 - if (path === "/") return location.pathname === "/"; 28 - return location.pathname.startsWith(path); 29 - }; 30 - 31 - useEffect(() => { 32 - if (isAuthenticated) { 33 - getUnreadNotificationCount() 34 - .then((data) => setUnreadCount(data.count || 0)) 35 - .catch(() => {}); 36 - const interval = setInterval(() => { 37 - getUnreadNotificationCount() 38 - .then((data) => setUnreadCount(data.count || 0)) 39 - .catch(() => {}); 40 - }, 60000); 41 - return () => clearInterval(interval); 42 - } 43 - }, [isAuthenticated]); 44 - 45 - useEffect(() => { 46 - const handleClickOutside = (e) => { 47 - if (menuRef.current && !menuRef.current.contains(e.target)) { 48 - setMenuOpen(false); 49 - } 50 - }; 51 - document.addEventListener("mousedown", handleClickOutside); 52 - return () => document.removeEventListener("mousedown", handleClickOutside); 53 - }, []); 54 - 55 - const getInitials = () => { 56 - if (user?.displayName) { 57 - return user.displayName.substring(0, 2).toUpperCase(); 58 - } 59 - if (user?.handle) { 60 - return user.handle.substring(0, 2).toUpperCase(); 61 - } 62 - return "U"; 63 - }; 64 - 65 - return ( 66 - <aside className="sidebar"> 67 - <Link to="/" className="sidebar-header"> 68 - <img src={logo} alt="Margin" className="sidebar-logo" /> 69 - <span className="sidebar-brand">Margin</span> 70 - </Link> 71 - 72 - <nav className="sidebar-nav"> 73 - <Link 74 - to="/" 75 - className={`sidebar-link ${isActive("/") ? "active" : ""}`} 76 - > 77 - <Home size={20} /> 78 - <span>Home</span> 79 - </Link> 80 - <Link 81 - to="/url" 82 - className={`sidebar-link ${isActive("/url") ? "active" : ""}`} 83 - > 84 - <Search size={20} /> 85 - <span>Browse</span> 86 - </Link> 87 - 88 - {isAuthenticated && ( 89 - <> 90 - <div className="sidebar-section-title">Library</div> 91 - <Link 92 - to="/highlights" 93 - className={`sidebar-link ${isActive("/highlights") ? "active" : ""}`} 94 - > 95 - <Highlighter size={20} /> 96 - <span>Highlights</span> 97 - </Link> 98 - <Link 99 - to="/bookmarks" 100 - className={`sidebar-link ${isActive("/bookmarks") ? "active" : ""}`} 101 - > 102 - <Bookmark size={20} /> 103 - <span>Bookmarks</span> 104 - </Link> 105 - <Link 106 - to="/collections" 107 - className={`sidebar-link ${isActive("/collections") ? "active" : ""}`} 108 - > 109 - <Folder size={20} /> 110 - <span>Collections</span> 111 - </Link> 112 - <Link 113 - to="/notifications" 114 - className={`sidebar-link ${isActive("/notifications") ? "active" : ""}`} 115 - onClick={() => setUnreadCount(0)} 116 - > 117 - <Bell size={20} /> 118 - <span>Notifications</span> 119 - {unreadCount > 0 && ( 120 - <span className="notification-badge">{unreadCount}</span> 121 - )} 122 - </Link> 123 - </> 124 - )} 125 - </nav> 126 - 127 - {isAuthenticated && ( 128 - <Link to="/new" className="sidebar-new-btn"> 129 - <PenSquare size={18} /> 130 - <span>New</span> 131 - </Link> 132 - )} 133 - 134 - <div className="sidebar-footer" ref={menuRef}> 135 - {!loading && 136 - (isAuthenticated ? ( 137 - <> 138 - <div 139 - className="sidebar-user" 140 - onClick={() => setMenuOpen(!menuOpen)} 141 - > 142 - <div className="sidebar-avatar"> 143 - {user?.avatar ? ( 144 - <img src={user.avatar} alt={user.displayName} /> 145 - ) : ( 146 - <span>{getInitials()}</span> 147 - )} 148 - </div> 149 - <div className="sidebar-user-info"> 150 - <div className="sidebar-user-name"> 151 - {user?.displayName || user?.handle} 152 - </div> 153 - <div className="sidebar-user-handle">@{user?.handle}</div> 154 - </div> 155 - <MoreHorizontal size={18} className="sidebar-user-menu" /> 156 - </div> 157 - 158 - {menuOpen && ( 159 - <div className="sidebar-dropdown"> 160 - <Link 161 - to={`/profile/${user?.did}`} 162 - className="sidebar-dropdown-item" 163 - onClick={() => setMenuOpen(false)} 164 - > 165 - <User size={16} /> 166 - View Profile 167 - </Link> 168 - <button 169 - onClick={() => { 170 - logout(); 171 - setMenuOpen(false); 172 - }} 173 - className="sidebar-dropdown-item danger" 174 - > 175 - <LogOut size={16} /> 176 - Sign Out 177 - </button> 178 - </div> 179 - )} 180 - </> 181 - ) : ( 182 - <Link to="/login" className="sidebar-new-btn" style={{ margin: 0 }}> 183 - Sign In 184 - </Link> 185 - ))} 186 - </div> 187 - </aside> 188 - ); 189 - }
+117 -255
web/src/components/SignUpModal.jsx
··· 1 1 import { useState, useEffect } from "react"; 2 2 import { X, ChevronRight, Loader2, AlertCircle } from "lucide-react"; 3 3 import { BlackskyIcon, NorthskyIcon, BlueskyIcon, TopphieIcon } from "./Icons"; 4 - import { describeServer, createAccount, startLogin } from "../api/client"; 4 + import { startSignup } from "../api/client"; 5 + import logo from "../assets/logo.svg"; 5 6 6 - const PROVIDERS = [ 7 + const RECOMMENDED_PROVIDER = { 8 + id: "margin", 9 + name: "Margin", 10 + service: "https://pds.margin.at", 11 + Icon: null, 12 + description: "Hosted by Margin, the easiest way to get started", 13 + isMargin: true, 14 + }; 15 + 16 + const OTHER_PROVIDERS = [ 7 17 { 8 18 id: "bluesky", 9 19 name: "Bluesky", ··· 24 34 service: "https://northsky.social", 25 35 Icon: NorthskyIcon, 26 36 description: "A Canadian-based worker-owned cooperative", 27 - inviteUrl: "https://northskysocial.com/join", 28 37 }, 29 38 { 30 39 id: "topphie", ··· 41 50 description: "An independent, self-hosted PDS instance", 42 51 }, 43 52 { 44 - id: "selfhosted", 45 - name: "Self-Hosted", 53 + id: "custom", 54 + name: "Custom", 46 55 service: "", 47 56 custom: true, 48 57 Icon: null, 49 - description: "Connect to your own Personal Data Server", 58 + description: "Connect to your own or another custom PDS", 50 59 }, 51 60 ]; 52 61 53 62 export default function SignUpModal({ onClose }) { 54 - const [step, setStep] = useState(1); 55 - const [selectedProvider, setSelectedProvider] = useState(null); 63 + const [showOtherProviders, setShowOtherProviders] = useState(false); 64 + const [showCustomInput, setShowCustomInput] = useState(false); 56 65 const [customService, setCustomService] = useState(""); 57 - const [formData, setFormData] = useState({ 58 - handle: "", 59 - email: "", 60 - password: "", 61 - inviteCode: "", 62 - }); 63 66 const [loading, setLoading] = useState(false); 64 67 const [error, setError] = useState(null); 65 - const [serverInfo, setServerInfo] = useState(null); 66 68 67 69 useEffect(() => { 68 70 document.body.style.overflow = "hidden"; ··· 71 73 }; 72 74 }, []); 73 75 74 - const handleProviderSelect = (provider) => { 75 - setSelectedProvider(provider); 76 - if (!provider.custom) { 77 - checkServer(provider.service); 78 - } else { 79 - setStep(1.5); 76 + const handleProviderSelect = async (provider) => { 77 + if (provider.custom) { 78 + setShowCustomInput(true); 79 + return; 80 80 } 81 - }; 82 81 83 - const checkServer = async (url) => { 84 82 setLoading(true); 85 83 setError(null); 84 + 86 85 try { 87 - let serviceUrl = url.trim(); 88 - if (!serviceUrl.startsWith("http")) { 89 - serviceUrl = `https://${serviceUrl}`; 86 + const result = await startSignup(provider.service); 87 + if (result.authorizationUrl) { 88 + window.location.href = result.authorizationUrl; 90 89 } 91 - 92 - const info = await describeServer(serviceUrl); 93 - setServerInfo({ 94 - ...info, 95 - service: serviceUrl, 96 - inviteCodeRequired: info.inviteCodeRequired ?? true, 97 - }); 98 - 99 - if (selectedProvider?.custom) { 100 - setSelectedProvider({ ...selectedProvider, service: serviceUrl }); 101 - } 102 - 103 - setStep(2); 104 90 } catch (err) { 105 91 console.error(err); 106 - setError("Could not connect to this PDS. Please check the URL."); 107 - } finally { 92 + setError("Could not connect to this provider. Please try again."); 108 93 setLoading(false); 109 94 } 110 95 }; 111 96 112 - const handleCreateAccount = async (e) => { 97 + const handleCustomSubmit = async (e) => { 113 98 e.preventDefault(); 114 - if (!serverInfo) return; 99 + if (!customService.trim()) return; 115 100 116 101 setLoading(true); 117 102 setError(null); 118 103 119 - let domain = 120 - serverInfo.selectedDomain || serverInfo.availableUserDomains[0]; 121 - if (!domain.startsWith(".")) { 122 - domain = "." + domain; 104 + let serviceUrl = customService.trim(); 105 + if (!serviceUrl.startsWith("http")) { 106 + serviceUrl = `https://${serviceUrl}`; 123 107 } 124 108 125 - const cleanHandle = formData.handle.trim().replace(/^@/, ""); 126 - const fullHandle = cleanHandle.endsWith(domain) 127 - ? cleanHandle 128 - : `${cleanHandle}${domain}`; 129 - 130 109 try { 131 - await createAccount(serverInfo.service, { 132 - handle: fullHandle, 133 - email: formData.email, 134 - password: formData.password, 135 - inviteCode: formData.inviteCode, 136 - }); 137 - 138 - const result = await startLogin(fullHandle); 110 + const result = await startSignup(serviceUrl); 139 111 if (result.authorizationUrl) { 140 112 window.location.href = result.authorizationUrl; 141 - } else { 142 - onClose(); 143 - alert("Account created! Please sign in."); 144 113 } 145 114 } catch (err) { 146 - setError(err.message || "Failed to create account"); 115 + console.error(err); 116 + setError("Could not connect to this PDS. Please check the URL."); 147 117 setLoading(false); 148 118 } 149 119 }; ··· 155 125 <X size={20} /> 156 126 </button> 157 127 158 - {step === 1 && ( 159 - <div className="signup-step"> 160 - <h2>Choose a Provider</h2> 161 - <p className="signup-subtitle"> 162 - Where would you like to host your account? 128 + {loading ? ( 129 + <div className="signup-step" style={{ textAlign: "center" }}> 130 + <Loader2 size={32} className="spinner" /> 131 + <p style={{ marginTop: "1rem", color: "var(--text-secondary)" }}> 132 + Connecting to provider... 163 133 </p> 164 - <div className="provider-grid"> 165 - {PROVIDERS.map((p) => ( 166 - <button 167 - key={p.id} 168 - className="provider-card" 169 - onClick={() => handleProviderSelect(p)} 170 - > 171 - <div className={`provider-icon ${p.wide ? "wide" : ""}`}> 172 - {p.Icon ? ( 173 - <p.Icon size={p.wide ? 32 : 32} /> 174 - ) : ( 175 - <span className="provider-initial">{p.name[0]}</span> 176 - )} 177 - </div> 178 - <div className="provider-info"> 179 - <h3>{p.name}</h3> 180 - <span>{p.description}</span> 181 - </div> 182 - <ChevronRight size={16} className="provider-arrow" /> 183 - </button> 184 - ))} 185 - </div> 186 134 </div> 187 - )} 188 - 189 - {step === 1.5 && ( 135 + ) : showCustomInput ? ( 190 136 <div className="signup-step"> 191 137 <h2>Custom Provider</h2> 192 - <form 193 - onSubmit={(e) => { 194 - e.preventDefault(); 195 - checkServer(customService); 196 - }} 197 - > 138 + <form onSubmit={handleCustomSubmit}> 198 139 <div className="form-group"> 199 140 <label>PDS address (e.g. pds.example.com)</label> 200 141 <input 201 142 type="text" 202 - className="login-input" 203 143 value={customService} 204 144 onChange={(e) => setCustomService(e.target.value)} 205 - placeholder="example.com" 145 + placeholder="pds.example.com" 206 146 autoFocus 207 147 /> 208 148 </div> 149 + 209 150 {error && ( 210 151 <div className="error-message"> 211 - <AlertCircle size={14} /> {error} 152 + <AlertCircle size={16} /> 153 + {error} 212 154 </div> 213 155 )} 156 + 214 157 <div className="modal-actions"> 215 158 <button 216 159 type="button" 217 - className="btn btn-ghost" 218 - onClick={() => setStep(1)} 160 + className="btn-secondary" 161 + onClick={() => { 162 + setShowCustomInput(false); 163 + setError(null); 164 + }} 219 165 > 220 166 Back 221 167 </button> 222 168 <button 223 169 type="submit" 224 - className="btn btn-primary" 225 - disabled={!customService || loading} 170 + className="btn-primary" 171 + disabled={!customService.trim()} 226 172 > 227 - {loading ? <Loader2 className="animate-spin" /> : "Next"} 173 + Continue 228 174 </button> 229 175 </div> 230 176 </form> 231 177 </div> 232 - )} 233 - 234 - {step === 2 && serverInfo && ( 178 + ) : ( 235 179 <div className="signup-step"> 236 - <div className="step-header"> 237 - <button className="btn-back" onClick={() => setStep(1)}> 238 - ← Back 239 - </button> 240 - <h2> 241 - Create Account on {selectedProvider?.name || "Custom PDS"} 242 - </h2> 243 - </div> 244 - 245 - <form onSubmit={handleCreateAccount} className="signup-form"> 246 - {serverInfo.inviteCodeRequired && ( 247 - <div className="form-group"> 248 - <label>Invite Code *</label> 249 - <input 250 - type="text" 251 - className="login-input" 252 - value={formData.inviteCode} 253 - onChange={(e) => 254 - setFormData({ ...formData, inviteCode: e.target.value }) 255 - } 256 - placeholder="bsky-social-xxxxx" 257 - required 258 - /> 259 - {selectedProvider?.inviteUrl && ( 260 - <p 261 - className="legal-text" 262 - style={{ textAlign: "left", marginTop: "4px" }} 263 - > 264 - Need an invite code?{" "} 265 - <a 266 - href={selectedProvider.inviteUrl} 267 - target="_blank" 268 - rel="noopener noreferrer" 269 - style={{ color: "var(--accent)" }} 270 - > 271 - Get one here 272 - </a> 273 - </p> 274 - )} 275 - </div> 276 - )} 180 + <h2>Create your account</h2> 181 + <p className="signup-subtitle"> 182 + Margin uses the AT Protocol — the same decentralized network that 183 + powers Bluesky. Your account will be hosted on a server of your 184 + choice. 185 + </p> 277 186 278 - <div className="form-group"> 279 - <label>Email Address</label> 280 - <input 281 - type="email" 282 - className="login-input" 283 - value={formData.email} 284 - onChange={(e) => 285 - setFormData({ ...formData, email: e.target.value }) 286 - } 287 - placeholder="you@example.com" 288 - required 289 - /> 187 + {error && ( 188 + <div className="error-message" style={{ marginBottom: "1rem" }}> 189 + <AlertCircle size={16} /> 190 + {error} 290 191 </div> 192 + )} 291 193 292 - <div className="form-group"> 293 - <label>Password</label> 294 - <input 295 - type="password" 296 - className="login-input" 297 - value={formData.password} 298 - onChange={(e) => 299 - setFormData({ ...formData, password: e.target.value }) 300 - } 301 - required 302 - /> 303 - </div> 304 - 305 - <div className="form-group"> 306 - <label>Handle</label> 307 - <div className="handle-input-group"> 308 - <input 309 - type="text" 310 - className="login-input" 311 - value={formData.handle} 312 - onChange={(e) => 313 - setFormData({ ...formData, handle: e.target.value }) 314 - } 315 - placeholder="username" 316 - required 317 - style={{ flex: 1 }} 194 + <div className="signup-recommended"> 195 + <div className="signup-recommended-badge">Recommended</div> 196 + <button 197 + className="provider-card provider-card-featured" 198 + onClick={() => handleProviderSelect(RECOMMENDED_PROVIDER)} 199 + > 200 + <div className="provider-icon"> 201 + <img 202 + src={logo} 203 + alt="Margin" 204 + style={{ width: 24, height: 24 }} 318 205 /> 319 - {serverInfo.availableUserDomains && 320 - serverInfo.availableUserDomains.length > 1 ? ( 321 - <select 322 - className="login-input" 323 - style={{ 324 - width: "auto", 325 - flex: "0 0 auto", 326 - paddingRight: "24px", 327 - }} 328 - onChange={(e) => { 329 - setServerInfo({ 330 - ...serverInfo, 331 - selectedDomain: e.target.value, 332 - }); 333 - }} 334 - value={ 335 - serverInfo.selectedDomain || 336 - serverInfo.availableUserDomains[0] 337 - } 338 - > 339 - {serverInfo.availableUserDomains.map((d) => ( 340 - <option key={d} value={d}> 341 - .{d.startsWith(".") ? d.substring(1) : d} 342 - </option> 343 - ))} 344 - </select> 345 - ) : ( 346 - <span className="handle-suffix"> 347 - {(() => { 348 - const d = 349 - serverInfo.availableUserDomains?.[0] || "bsky.social"; 350 - return d.startsWith(".") ? d : `.${d}`; 351 - })()} 352 - </span> 353 - )} 354 206 </div> 355 - </div> 356 - 357 - {error && ( 358 - <div className="error-message"> 359 - <AlertCircle size={14} /> {error} 207 + <div className="provider-info"> 208 + <h3>{RECOMMENDED_PROVIDER.name}</h3> 209 + <span>{RECOMMENDED_PROVIDER.description}</span> 360 210 </div> 361 - )} 211 + <ChevronRight size={16} className="provider-arrow" /> 212 + </button> 213 + </div> 362 214 363 - <button 364 - type="submit" 365 - className="btn btn-primary full-width" 366 - disabled={loading} 367 - > 368 - {loading ? "Creating Account..." : "Create Account"} 369 - </button> 215 + <button 216 + type="button" 217 + className="signup-toggle-others" 218 + onClick={() => setShowOtherProviders(!showOtherProviders)} 219 + > 220 + {showOtherProviders ? "Hide other options" : "More options"} 221 + <ChevronRight 222 + size={14} 223 + className={`toggle-chevron ${showOtherProviders ? "open" : ""}`} 224 + /> 225 + </button> 370 226 371 - <p className="legal-text"> 372 - By creating an account, you agree to {selectedProvider?.name} 373 - &apos;s{" "} 374 - {serverInfo.links?.termsOfService ? ( 375 - <a 376 - href={serverInfo.links.termsOfService} 377 - target="_blank" 378 - rel="noopener noreferrer" 379 - style={{ color: "var(--accent)" }} 227 + {showOtherProviders && ( 228 + <div className="provider-grid"> 229 + {OTHER_PROVIDERS.map((p) => ( 230 + <button 231 + key={p.id} 232 + className="provider-card" 233 + onClick={() => handleProviderSelect(p)} 380 234 > 381 - Terms of Service 382 - </a> 383 - ) : ( 384 - "Terms of Service" 385 - )} 386 - . 387 - </p> 388 - </form> 235 + <div className={`provider-icon ${p.wide ? "wide" : ""}`}> 236 + {p.Icon ? ( 237 + <p.Icon size={32} /> 238 + ) : ( 239 + <span className="provider-initial">{p.name[0]}</span> 240 + )} 241 + </div> 242 + <div className="provider-info"> 243 + <h3>{p.name}</h3> 244 + <span>{p.description}</span> 245 + </div> 246 + <ChevronRight size={16} className="provider-arrow" /> 247 + </button> 248 + ))} 249 + </div> 250 + )} 389 251 </div> 390 252 )} 391 253 </div>
+408
web/src/components/TopNav.jsx
··· 1 + import { useState, useRef, useEffect } from "react"; 2 + import { Link, useLocation } from "react-router-dom"; 3 + import { useAuth } from "../context/AuthContext"; 4 + import { useTheme } from "../context/ThemeContext"; 5 + import { 6 + Home, 7 + Search, 8 + Folder, 9 + Bell, 10 + PenSquare, 11 + User, 12 + LogOut, 13 + ChevronDown, 14 + Highlighter, 15 + Bookmark, 16 + Sun, 17 + Moon, 18 + Monitor, 19 + ExternalLink, 20 + Menu, 21 + X, 22 + } from "lucide-react"; 23 + import { 24 + SiFirefox, 25 + SiGooglechrome, 26 + SiGithub, 27 + SiBluesky, 28 + SiDiscord, 29 + } from "react-icons/si"; 30 + import { FaEdge } from "react-icons/fa"; 31 + import tangledLogo from "../assets/tangled.svg"; 32 + import { getUnreadNotificationCount } from "../api/client"; 33 + import logo from "../assets/logo.svg"; 34 + 35 + const isFirefox = 36 + typeof navigator !== "undefined" && /Firefox/i.test(navigator.userAgent); 37 + const isEdge = 38 + typeof navigator !== "undefined" && /Edg/i.test(navigator.userAgent); 39 + 40 + function getExtensionInfo() { 41 + if (isFirefox) { 42 + return { 43 + url: "https://addons.mozilla.org/en-US/firefox/addon/margin/", 44 + icon: SiFirefox, 45 + label: "Firefox", 46 + }; 47 + } 48 + if (isEdge) { 49 + return { 50 + url: "https://microsoftedge.microsoft.com/addons/detail/margin/nfjnmllpdgcdnhmmggjihjbidmeadddn", 51 + icon: FaEdge, 52 + label: "Edge", 53 + }; 54 + } 55 + return { 56 + url: "https://chromewebstore.google.com/detail/margin/cgpmbiiagnehkikhcbnhiagfomajncpa/", 57 + icon: SiGooglechrome, 58 + label: "Chrome", 59 + }; 60 + } 61 + 62 + export default function TopNav() { 63 + const { user, isAuthenticated, logout, loading } = useAuth(); 64 + const { theme, setTheme } = useTheme(); 65 + const location = useLocation(); 66 + const [userMenuOpen, setUserMenuOpen] = useState(false); 67 + const [moreMenuOpen, setMoreMenuOpen] = useState(false); 68 + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); 69 + const [unreadCount, setUnreadCount] = useState(0); 70 + const userMenuRef = useRef(null); 71 + const moreMenuRef = useRef(null); 72 + 73 + const isActive = (path) => { 74 + if (path === "/") return location.pathname === "/"; 75 + return location.pathname.startsWith(path); 76 + }; 77 + 78 + const ext = getExtensionInfo(); 79 + const ExtIcon = ext.icon; 80 + 81 + useEffect(() => { 82 + if (isAuthenticated) { 83 + getUnreadNotificationCount() 84 + .then((data) => setUnreadCount(data.count || 0)) 85 + .catch(() => {}); 86 + const interval = setInterval(() => { 87 + getUnreadNotificationCount() 88 + .then((data) => setUnreadCount(data.count || 0)) 89 + .catch(() => {}); 90 + }, 60000); 91 + return () => clearInterval(interval); 92 + } 93 + }, [isAuthenticated]); 94 + 95 + useEffect(() => { 96 + const handleClickOutside = (e) => { 97 + if (userMenuRef.current && !userMenuRef.current.contains(e.target)) { 98 + setUserMenuOpen(false); 99 + } 100 + if (moreMenuRef.current && !moreMenuRef.current.contains(e.target)) { 101 + setMoreMenuOpen(false); 102 + } 103 + }; 104 + document.addEventListener("mousedown", handleClickOutside); 105 + return () => document.removeEventListener("mousedown", handleClickOutside); 106 + }, []); 107 + 108 + const closeMobileMenu = () => setMobileMenuOpen(false); 109 + 110 + const getInitials = () => { 111 + if (user?.displayName) 112 + return user.displayName.substring(0, 2).toUpperCase(); 113 + if (user?.handle) return user.handle.substring(0, 2).toUpperCase(); 114 + return "U"; 115 + }; 116 + 117 + const cycleTheme = () => { 118 + const next = 119 + theme === "system" ? "light" : theme === "light" ? "dark" : "system"; 120 + setTheme(next); 121 + }; 122 + 123 + return ( 124 + <header className="top-nav"> 125 + <div className="top-nav-inner"> 126 + <Link to="/home" className="top-nav-logo"> 127 + <img src={logo} alt="Margin" /> 128 + <span>Margin</span> 129 + </Link> 130 + 131 + <nav className="top-nav-links"> 132 + <Link 133 + to="/home" 134 + className={`top-nav-link ${isActive("/home") ? "active" : ""}`} 135 + > 136 + Home 137 + </Link> 138 + <Link 139 + to="/url" 140 + className={`top-nav-link ${isActive("/url") ? "active" : ""}`} 141 + > 142 + Browse 143 + </Link> 144 + {isAuthenticated && ( 145 + <> 146 + <Link 147 + to="/highlights" 148 + className={`top-nav-link ${isActive("/highlights") ? "active" : ""}`} 149 + > 150 + Highlights 151 + </Link> 152 + <Link 153 + to="/bookmarks" 154 + className={`top-nav-link ${isActive("/bookmarks") ? "active" : ""}`} 155 + > 156 + Bookmarks 157 + </Link> 158 + <Link 159 + to="/collections" 160 + className={`top-nav-link ${isActive("/collections") ? "active" : ""}`} 161 + > 162 + Collections 163 + </Link> 164 + </> 165 + )} 166 + </nav> 167 + 168 + <div className="top-nav-actions"> 169 + <a 170 + href={ext.url} 171 + target="_blank" 172 + rel="noopener noreferrer" 173 + className="top-nav-link extension-link" 174 + title={`Get ${ext.label} Extension`} 175 + > 176 + <ExtIcon size={16} /> 177 + <span>Get Extension</span> 178 + </a> 179 + 180 + <div className="top-nav-dropdown" ref={moreMenuRef}> 181 + <button 182 + className="top-nav-icon-btn" 183 + onClick={() => setMoreMenuOpen(!moreMenuOpen)} 184 + title="More" 185 + > 186 + <ChevronDown size={18} /> 187 + </button> 188 + {moreMenuOpen && ( 189 + <div className="dropdown-menu dropdown-right"> 190 + <a 191 + href="https://github.com/margin-at/margin" 192 + target="_blank" 193 + rel="noopener noreferrer" 194 + className="dropdown-item" 195 + > 196 + <SiGithub size={16} /> 197 + GitHub 198 + <ExternalLink size={12} className="dropdown-external" /> 199 + </a> 200 + <a 201 + href="https://tangled.sh/@margin.at/margin" 202 + target="_blank" 203 + rel="noopener noreferrer" 204 + className="dropdown-item" 205 + > 206 + <span className="tangled-icon-wrapper"> 207 + <img src={tangledLogo} alt="" /> 208 + </span> 209 + Tangled 210 + <ExternalLink size={12} className="dropdown-external" /> 211 + </a> 212 + <a 213 + href="https://bsky.app/profile/margin.at" 214 + target="_blank" 215 + rel="noopener noreferrer" 216 + className="dropdown-item" 217 + > 218 + <SiBluesky size={16} /> 219 + Bluesky 220 + <ExternalLink size={12} className="dropdown-external" /> 221 + </a> 222 + <a 223 + href="https://discord.gg/ZQbkGqwzBH" 224 + target="_blank" 225 + rel="noopener noreferrer" 226 + className="dropdown-item" 227 + > 228 + <SiDiscord size={16} /> 229 + Discord 230 + <ExternalLink size={12} className="dropdown-external" /> 231 + </a> 232 + <div className="dropdown-divider" /> 233 + <button className="dropdown-item" onClick={cycleTheme}> 234 + {theme === "system" && <Monitor size={16} />} 235 + {theme === "dark" && <Moon size={16} />} 236 + {theme === "light" && <Sun size={16} />} 237 + Theme: {theme} 238 + </button> 239 + <div className="dropdown-divider" /> 240 + <Link 241 + to="/privacy" 242 + className="dropdown-item" 243 + onClick={() => setMoreMenuOpen(false)} 244 + > 245 + Privacy 246 + </Link> 247 + <Link 248 + to="/terms" 249 + className="dropdown-item" 250 + onClick={() => setMoreMenuOpen(false)} 251 + > 252 + Terms 253 + </Link> 254 + </div> 255 + )} 256 + </div> 257 + 258 + {isAuthenticated && ( 259 + <> 260 + <Link 261 + to="/notifications" 262 + className="top-nav-icon-btn" 263 + onClick={() => setUnreadCount(0)} 264 + title="Notifications" 265 + > 266 + <Bell size={18} /> 267 + {unreadCount > 0 && <span className="notif-dot" />} 268 + </Link> 269 + 270 + <Link to="/new" className="top-nav-new-btn"> 271 + <PenSquare size={16} /> 272 + <span>New</span> 273 + </Link> 274 + </> 275 + )} 276 + 277 + {!loading && 278 + (isAuthenticated ? ( 279 + <div className="top-nav-dropdown" ref={userMenuRef}> 280 + <button 281 + className="top-nav-avatar" 282 + onClick={() => setUserMenuOpen(!userMenuOpen)} 283 + > 284 + {user?.avatar ? ( 285 + <img src={user.avatar} alt={user.displayName} /> 286 + ) : ( 287 + <span>{getInitials()}</span> 288 + )} 289 + </button> 290 + {userMenuOpen && ( 291 + <div className="dropdown-menu dropdown-right"> 292 + <div className="dropdown-user-info"> 293 + <span className="dropdown-user-name"> 294 + {user?.displayName || user?.handle} 295 + </span> 296 + <span className="dropdown-user-handle"> 297 + @{user?.handle} 298 + </span> 299 + </div> 300 + <div className="dropdown-divider" /> 301 + <Link 302 + to={`/profile/${user?.did}`} 303 + className="dropdown-item" 304 + onClick={() => setUserMenuOpen(false)} 305 + > 306 + <User size={16} /> 307 + View Profile 308 + </Link> 309 + <button 310 + onClick={() => { 311 + logout(); 312 + setUserMenuOpen(false); 313 + }} 314 + className="dropdown-item danger" 315 + > 316 + <LogOut size={16} /> 317 + Sign Out 318 + </button> 319 + </div> 320 + )} 321 + </div> 322 + ) : ( 323 + <Link to="/login" className="top-nav-new-btn"> 324 + Sign In 325 + </Link> 326 + ))} 327 + 328 + <button 329 + className="top-nav-mobile-toggle" 330 + onClick={() => setMobileMenuOpen(!mobileMenuOpen)} 331 + > 332 + {mobileMenuOpen ? <X size={22} /> : <Menu size={22} />} 333 + </button> 334 + </div> 335 + </div> 336 + 337 + {mobileMenuOpen && ( 338 + <div className="mobile-menu"> 339 + <Link 340 + to="/home" 341 + className={`mobile-menu-link ${isActive("/home") ? "active" : ""}`} 342 + onClick={closeMobileMenu} 343 + > 344 + <Home size={20} /> Home 345 + </Link> 346 + <Link 347 + to="/url" 348 + className={`mobile-menu-link ${isActive("/url") ? "active" : ""}`} 349 + onClick={closeMobileMenu} 350 + > 351 + <Search size={20} /> Browse 352 + </Link> 353 + {isAuthenticated && ( 354 + <> 355 + <Link 356 + to="/highlights" 357 + className={`mobile-menu-link ${isActive("/highlights") ? "active" : ""}`} 358 + onClick={closeMobileMenu} 359 + > 360 + <Highlighter size={20} /> Highlights 361 + </Link> 362 + <Link 363 + to="/bookmarks" 364 + className={`mobile-menu-link ${isActive("/bookmarks") ? "active" : ""}`} 365 + onClick={closeMobileMenu} 366 + > 367 + <Bookmark size={20} /> Bookmarks 368 + </Link> 369 + <Link 370 + to="/collections" 371 + className={`mobile-menu-link ${isActive("/collections") ? "active" : ""}`} 372 + onClick={closeMobileMenu} 373 + > 374 + <Folder size={20} /> Collections 375 + </Link> 376 + <Link 377 + to="/notifications" 378 + className={`mobile-menu-link ${isActive("/notifications") ? "active" : ""}`} 379 + onClick={closeMobileMenu} 380 + > 381 + <Bell size={20} /> Notifications 382 + {unreadCount > 0 && ( 383 + <span className="notification-badge">{unreadCount}</span> 384 + )} 385 + </Link> 386 + <Link 387 + to="/new" 388 + className={`mobile-menu-link ${isActive("/new") ? "active" : ""}`} 389 + onClick={closeMobileMenu} 390 + > 391 + <PenSquare size={20} /> New 392 + </Link> 393 + </> 394 + )} 395 + <div className="mobile-menu-divider" /> 396 + <a 397 + href={ext.url} 398 + target="_blank" 399 + rel="noopener noreferrer" 400 + className="mobile-menu-link" 401 + > 402 + <ExtIcon size={20} /> Get Extension 403 + </a> 404 + </div> 405 + )} 406 + </header> 407 + ); 408 + }
+131 -96
web/src/css/annotations.css
··· 1 1 .annotation-detail-page { 2 - max-width: 680px; 2 + max-width: 640px; 3 3 margin: 0 auto; 4 - padding: 24px 16px; 5 4 min-height: 100vh; 6 5 } 7 6 8 7 .annotation-detail-header { 9 - margin-bottom: 24px; 8 + margin-bottom: var(--spacing-md); 10 9 } 11 10 12 11 .back-link { ··· 14 13 align-items: center; 15 14 color: var(--text-tertiary); 16 15 text-decoration: none; 17 - font-size: 0.9rem; 16 + font-size: 0.8rem; 18 17 font-weight: 500; 19 18 transition: color 0.15s; 20 19 } ··· 24 23 } 25 24 26 25 .replies-section { 27 - margin-top: 32px; 26 + margin-top: var(--spacing-lg); 28 27 border-top: 1px solid var(--border); 29 - padding-top: 24px; 28 + padding-top: var(--spacing-md); 30 29 } 31 30 32 31 .replies-title { 33 32 display: flex; 34 33 align-items: center; 35 - gap: 8px; 36 - font-size: 1.1rem; 34 + gap: 6px; 35 + font-size: 0.9rem; 37 36 font-weight: 600; 38 37 color: var(--text-primary); 39 - margin-bottom: 20px; 38 + margin-bottom: var(--spacing-md); 40 39 } 41 40 42 41 .annotation-card { 43 42 display: flex; 44 43 flex-direction: column; 45 - gap: 12px; 46 - padding: 20px 0; 47 - border-bottom: 1px solid var(--border); 48 - transition: background 0.15s ease; 44 + gap: 8px; 45 + padding: 16px 20px; 46 + transition: all 0.15s ease; 47 + width: 100%; 48 + box-sizing: border-box; 49 + overflow: visible; 50 + background: var(--bg-primary); 51 + border: none; 52 + position: relative; 53 + } 54 + 55 + .feed > .annotation-card, 56 + .feed > .card { 57 + border-radius: var(--radius-lg); 58 + border: 1px solid var(--border); 59 + background: var(--bg-card) !important; 60 + overflow: hidden; 49 61 } 50 62 51 - .annotation-card:last-child { 52 - border-bottom: none; 63 + /* 64 + .feed > .annotation-card:first-child, 65 + .feed > .card:first-child { 66 + border-top-left-radius: var(--radius-lg) !important; 67 + border-top-right-radius: var(--radius-lg) !important; 53 68 } 69 + 70 + .feed > .annotation-card:last-child, 71 + .feed > .card:last-child { 72 + border-bottom-left-radius: var(--radius-lg) !important; 73 + border-bottom-right-radius: var(--radius-lg) !important; 74 + } 75 + 76 + .feed > .annotation-card:only-child, 77 + .feed > .card:only-child { 78 + border-radius: var(--radius-lg) !important; 79 + } 80 + */ 54 81 55 82 .annotation-header { 56 83 display: flex; 57 84 justify-content: space-between; 58 85 align-items: flex-start; 59 - gap: 12px; 86 + gap: var(--spacing-sm); 60 87 } 61 88 62 89 .annotation-header-left { 63 90 display: flex; 64 91 align-items: center; 65 - gap: 10px; 92 + gap: 8px; 66 93 flex: 1; 67 94 min-width: 0; 68 95 } 69 96 70 97 .annotation-avatar { 71 - width: 36px; 72 - height: 36px; 73 - min-width: 36px; 74 - border-radius: 50%; 98 + width: 32px; 99 + height: 32px; 100 + min-width: 32px; 101 + border-radius: var(--radius-full); 75 102 background: var(--bg-tertiary); 76 103 display: flex; 77 104 align-items: center; 78 105 justify-content: center; 79 106 font-weight: 600; 80 - font-size: 0.85rem; 107 + font-size: 0.75rem; 81 108 color: var(--text-secondary); 82 109 overflow: hidden; 83 110 } ··· 92 119 display: flex; 93 120 flex-direction: column; 94 121 justify-content: center; 95 - line-height: 1.3; 122 + line-height: 1.4; 123 + min-width: 0; 124 + flex: 1; 96 125 } 97 126 98 127 .annotation-avatar-link { 99 128 text-decoration: none; 100 - border-radius: 50%; 129 + border-radius: var(--radius-full); 101 130 } 102 131 103 132 .annotation-author-row { 104 133 display: flex; 105 134 align-items: baseline; 106 - gap: 6px; 135 + gap: 8px; 107 136 flex-wrap: wrap; 108 137 } 109 138 110 139 .annotation-author { 111 140 font-weight: 600; 112 141 color: var(--text-primary); 113 - font-size: 0.9rem; 142 + font-size: 0.875rem; 114 143 } 115 144 116 145 .annotation-handle { 117 - font-size: 0.85rem; 146 + font-size: 0.8rem; 118 147 color: var(--text-tertiary); 119 148 text-decoration: none; 120 149 } ··· 131 160 .annotation-content { 132 161 display: flex; 133 162 flex-direction: column; 134 - gap: 10px; 135 - padding-left: 46px; 163 + gap: 8px; 164 + padding-left: 0; 165 + max-width: 100%; 166 + overflow: hidden; 136 167 } 137 168 138 169 .annotation-source { 139 170 display: inline-flex; 140 171 align-items: center; 141 172 gap: 6px; 142 - font-size: 0.75rem; 143 - color: var(--text-tertiary); 173 + font-size: 0.8rem; 174 + color: var(--accent); 144 175 text-decoration: none; 145 176 transition: color 0.15s ease; 146 177 max-width: 100%; 147 178 overflow: hidden; 148 - text-overflow: ellipsis; 149 - white-space: nowrap; 150 179 } 151 180 152 181 .annotation-source:hover { 153 - color: var(--text-secondary); 154 182 text-decoration: underline; 155 183 } 156 184 157 185 .annotation-source-title { 158 - color: var(--text-tertiary); 159 - opacity: 0.7; 186 + color: var(--text-primary); 187 + font-weight: 500; 188 + overflow: hidden; 189 + text-overflow: ellipsis; 190 + white-space: nowrap; 160 191 } 161 192 162 193 .annotation-highlight { 163 194 display: block; 164 195 position: relative; 165 - padding-left: 12px; 166 - margin: 4px 0; 196 + padding: 10px 14px; 197 + margin: 0; 167 198 text-decoration: none; 168 - border-left: 2px solid var(--border); 199 + background: var(--bg-tertiary); 200 + border-left: 3px solid var(--accent); 201 + border-radius: 0 var(--radius-md) var(--radius-md) 0; 169 202 transition: all 0.15s ease; 203 + max-width: 100%; 204 + overflow: hidden; 170 205 } 171 206 172 207 .annotation-highlight:hover { 173 - border-left-color: var(--text-secondary); 208 + background: var(--bg-hover); 174 209 } 175 210 176 211 .annotation-highlight mark { 177 212 background: transparent; 178 213 color: var(--text-primary); 179 214 font-style: italic; 180 - font-size: 1rem; 181 - line-height: 1.6; 215 + font-size: 0.875rem; 216 + line-height: 1.5; 182 217 font-weight: 400; 183 - font-family: var(--font-serif, var(--font-sans)); 184 - display: inline; 185 - overflow-wrap: anywhere; 186 - word-break: break-all; 187 - padding-right: 4px; 218 + display: block; 219 + overflow-wrap: break-word; 220 + word-break: break-word; 188 221 } 189 222 190 223 .annotation-text { 191 - font-size: 0.95rem; 192 - line-height: 1.6; 224 + font-size: 1rem; 225 + line-height: 1.7; 193 226 color: var(--text-primary); 194 227 white-space: pre-wrap; 195 228 } ··· 198 231 display: flex; 199 232 flex-wrap: wrap; 200 233 gap: 6px; 201 - margin-top: 4px; 234 + margin-top: 2px; 202 235 } 203 236 204 237 .annotation-tag { 205 - font-size: 0.8rem; 238 + font-size: 0.75rem; 206 239 color: var(--accent); 207 240 text-decoration: none; 208 241 font-weight: 500; 209 - opacity: 0.9; 210 242 transition: opacity 0.15s; 211 243 } 212 244 213 245 .annotation-tag:hover { 214 - opacity: 1; 246 + opacity: 0.8; 215 247 text-decoration: underline; 216 248 } 217 249 218 250 .annotation-actions { 219 251 display: flex; 220 252 align-items: center; 221 - justify-content: space-between; 253 + justify-content: flex-start; 254 + gap: 4px; 255 + padding-left: 0; 222 256 margin-top: 4px; 223 - padding-left: 46px; 257 + position: relative; 224 258 } 225 259 226 260 .annotation-actions-left { 227 261 display: flex; 228 262 align-items: center; 229 - gap: 16px; 263 + gap: 8px; 230 264 } 231 265 232 266 .annotation-action { 233 267 display: flex; 234 268 align-items: center; 235 - gap: 6px; 269 + gap: 5px; 236 270 color: var(--text-tertiary); 237 271 font-size: 0.8rem; 238 272 font-weight: 500; 239 - padding: 6px; 240 - margin-left: -6px; 241 - border-radius: var(--radius-sm); 273 + padding: 6px 10px; 274 + border-radius: var(--radius-md); 242 275 transition: all 0.15s ease; 243 276 background: transparent; 244 277 cursor: pointer; ··· 251 284 } 252 285 253 286 .annotation-action.liked { 254 - color: #ef4444; 287 + color: var(--error); 255 288 } 256 289 257 290 .annotation-action.liked svg { 258 - fill: #ef4444; 291 + fill: var(--error); 259 292 } 260 293 261 294 .annotation-action.active { ··· 263 296 } 264 297 265 298 .action-icon-only { 266 - padding: 6px; 299 + padding: 4px; 267 300 } 268 301 269 302 .annotation-header-right { ··· 276 309 } 277 310 278 311 .inline-replies { 279 - margin-top: 12px; 280 - padding-left: 46px; 312 + margin-top: var(--spacing-sm); 313 + padding-left: 0; 314 + position: relative; 281 315 } 282 316 283 317 .annotation-text, ··· 288 322 max-width: 100%; 289 323 } 290 324 291 - .annotation-highlight mark { 292 - overflow-wrap: break-word; 293 - word-break: break-word; 294 - display: inline; 295 - } 296 - 297 325 .annotation-header-left, 298 326 .annotation-meta, 299 327 .reply-meta { ··· 306 334 max-width: 100%; 307 335 } 308 336 309 - .annotation-source { 310 - max-width: 100%; 311 - } 312 - 313 337 @media (max-width: 768px) { 314 338 .annotation-content, 315 339 .annotation-actions, ··· 320 344 .annotation-header-right { 321 345 opacity: 1; 322 346 } 347 + 348 + .annotation-card { 349 + padding: 16px; 350 + } 351 + 352 + .annotation-avatar { 353 + width: 36px; 354 + height: 36px; 355 + min-width: 36px; 356 + } 323 357 } 324 358 325 359 .replies-list-threaded { 326 - margin-top: 16px; 360 + margin-top: var(--spacing-md); 327 361 display: flex; 328 362 flex-direction: column; 329 363 } ··· 331 365 .reply-card-threaded { 332 366 position: relative; 333 367 padding-left: 0; 368 + padding: var(--spacing-sm) 0; 334 369 transition: background 0.15s; 335 370 } 336 371 337 372 .reply-header { 338 373 display: flex; 339 374 align-items: center; 340 - gap: 10px; 341 - margin-bottom: 6px; 375 + gap: 8px; 376 + margin-bottom: 4px; 342 377 } 343 378 344 379 .reply-avatar { 345 380 width: 28px; 346 381 height: 28px; 347 - border-radius: 50%; 382 + border-radius: var(--radius-full); 348 383 background: var(--bg-tertiary); 349 384 overflow: hidden; 350 385 flex-shrink: 0; ··· 368 403 .reply-meta { 369 404 display: flex; 370 405 align-items: baseline; 371 - gap: 6px; 406 + gap: 8px; 372 407 flex: 1; 373 408 min-width: 0; 374 409 } 375 410 376 411 .reply-author { 377 412 font-weight: 600; 378 - font-size: 0.85rem; 413 + font-size: 0.875rem; 379 414 color: var(--text-primary); 380 415 white-space: nowrap; 381 416 overflow: hidden; ··· 392 427 } 393 428 394 429 .reply-time { 395 - font-size: 0.75rem; 430 + font-size: 0.8rem; 396 431 color: var(--text-tertiary); 397 432 white-space: nowrap; 398 433 } ··· 407 442 line-height: 1.5; 408 443 color: var(--text-primary); 409 444 margin: 0; 410 - padding-left: 38px; 445 + padding-left: 36px; 411 446 } 412 447 413 448 .reply-actions { ··· 428 463 padding: 4px; 429 464 color: var(--text-tertiary); 430 465 cursor: pointer; 431 - border-radius: 4px; 466 + border-radius: var(--radius-sm); 432 467 display: flex; 433 468 align-items: center; 434 469 justify-content: center; ··· 440 475 } 441 476 442 477 .reply-action-delete:hover { 443 - color: #ef4444; 444 - background: rgba(239, 68, 68, 0.1); 478 + color: var(--error); 479 + background: rgba(255, 69, 58, 0.1); 445 480 } 446 481 447 482 .reply-form { 448 483 border: 1px solid var(--border); 449 484 border-radius: var(--radius-md); 450 - padding: 16px; 485 + padding: var(--spacing-md); 451 486 background: var(--bg-secondary); 452 - margin-bottom: 24px; 487 + margin-bottom: var(--spacing-md); 453 488 } 454 489 455 490 .replying-to-banner { ··· 457 492 justify-content: space-between; 458 493 align-items: center; 459 494 background: var(--bg-tertiary); 460 - padding: 8px 12px; 495 + padding: 6px 10px; 461 496 border-radius: var(--radius-sm); 462 - margin-bottom: 12px; 463 - font-size: 0.85rem; 497 + margin-bottom: var(--spacing-sm); 498 + font-size: 0.8rem; 464 499 color: var(--text-secondary); 465 500 } 466 501 ··· 469 504 border: none; 470 505 color: var(--text-tertiary); 471 506 cursor: pointer; 472 - font-size: 1.2rem; 507 + font-size: 1rem; 473 508 padding: 0 4px; 474 509 line-height: 1; 475 510 } ··· 483 518 background: var(--bg-primary); 484 519 border: 1px solid var(--border); 485 520 border-radius: var(--radius-sm); 486 - padding: 12px; 521 + padding: 10px 12px; 487 522 color: var(--text-primary); 488 523 font-family: inherit; 489 - font-size: 0.95rem; 524 + font-size: 0.875rem; 490 525 resize: vertical; 491 - min-height: 80px; 526 + min-height: 60px; 492 527 transition: border-color 0.15s; 493 528 display: block; 494 529 box-sizing: border-box; ··· 502 537 .reply-form-actions { 503 538 display: flex; 504 539 justify-content: flex-end; 505 - margin-top: 12px; 540 + margin-top: var(--spacing-sm); 506 541 } 507 542 508 543 .rich-text-link {
+136 -80
web/src/css/base.css
··· 1 + @import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,400;0,500;0,600;1,400&family=IBM+Plex+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap"); 2 + 1 3 :root { 2 - --bg-primary: #09090b; 3 - --bg-secondary: #0f0f12; 4 - --bg-tertiary: #18181b; 5 - --bg-card: #09090b; 6 - --bg-elevated: #18181b; 7 - --text-primary: #e4e4e7; 8 - --text-secondary: #a1a1aa; 9 - --text-tertiary: #71717a; 10 - --border: #27272a; 11 - --border-hover: #3f3f46; 12 - --accent: #6366f1; 13 - --accent-hover: #4f46e5; 14 - --accent-subtle: rgba(99, 102, 241, 0.1); 15 - --accent-text: #818cf8; 16 - --success: #10b981; 17 - --error: #ef4444; 18 - --warning: #f59e0b; 19 - --info: #3b82f6; 20 - --radius-sm: 4px; 21 - --radius-md: 6px; 22 - --radius-lg: 8px; 4 + --bg-primary: #0a0a0d; 5 + --bg-secondary: #121216; 6 + --bg-tertiary: #1a1a1f; 7 + --bg-card: #0f0f13; 8 + --bg-elevated: #18181d; 9 + --bg-hover: #1e1e24; 10 + 11 + --glass-border: rgba(234, 234, 238, 0.08); 12 + --glass-bg: rgba(10, 10, 13, 0.92); 13 + 14 + --text-primary: #eaeaee; 15 + --text-secondary: #b7b6c5; 16 + --text-tertiary: #6e6d7a; 17 + 18 + --border: rgba(183, 182, 197, 0.12); 19 + --border-hover: rgba(183, 182, 197, 0.2); 20 + --accent: #957a86; 21 + --accent-hover: #a98d98; 22 + --accent-subtle: rgba(149, 122, 134, 0.15); 23 + --accent-text: #c4a8b2; 24 + 25 + --success: #7fb069; 26 + --error: #d97766; 27 + --warning: #e8a54b; 28 + --info: #6eb5ff; 29 + 30 + --radius-xs: 4px; 31 + --radius-sm: 6px; 32 + --radius-md: 8px; 33 + --radius-lg: 12px; 34 + --radius-xl: 16px; 23 35 --radius-full: 9999px; 24 - --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); 25 - --shadow-md: 26 - 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); 27 - --shadow-lg: 28 - 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); 29 - --font-sans: 30 - "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; 31 - --font-mono: 32 - "JetBrains Mono", source-code-pro, Menlo, Monaco, Consolas, monospace; 33 - --nav-bg: rgba(9, 9, 11, 0.9); 36 + 37 + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3); 38 + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4); 39 + --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5); 40 + --shadow-glow: 0 0 20px rgba(149, 122, 134, 0.2); 41 + 42 + --font-sans: "IBM Plex Sans", -apple-system, BlinkMacSystemFont, sans-serif; 43 + --font-display: "IBM Plex Sans", sans-serif; 44 + --font-mono: "IBM Plex Mono", monospace; 45 + 46 + --nav-bg: rgba(10, 10, 13, 0.95); 47 + 48 + --sidebar-width: 200px; 49 + --right-sidebar-width: 260px; 50 + --content-max-width: 600px; 51 + --spacing-xs: 4px; 52 + --spacing-sm: 8px; 53 + --spacing-md: 12px; 54 + --spacing-lg: 20px; 55 + --spacing-xl: 28px; 34 56 } 35 57 36 58 [data-theme="light"] { 37 - --bg-primary: #ffffff; 38 - --bg-secondary: #f4f4f5; 39 - --bg-tertiary: #e4e4e7; 59 + --bg-primary: #f8f8fa; 60 + --bg-secondary: #ffffff; 61 + --bg-tertiary: #f0f0f4; 40 62 --bg-card: #ffffff; 41 - --bg-elevated: #f4f4f5; 42 - --text-primary: #18181b; 43 - --text-secondary: #52525b; 44 - --text-tertiary: #71717a; 45 - --border: #e4e4e7; 46 - --border-hover: #d4d4d8; 47 - --accent: #4f46e5; 48 - --accent-hover: #4338ca; 49 - --accent-subtle: rgba(79, 70, 229, 0.1); 50 - --accent-text: #4f46e5; 51 - --success: #059669; 52 - --error: #dc2626; 53 - --warning: #d97706; 54 - --info: #2563eb; 55 - --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); 56 - --shadow-md: 57 - 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); 58 - --nav-bg: rgba(255, 255, 255, 0.9); 63 + --bg-elevated: #ffffff; 64 + --bg-hover: #eeeef2; 65 + 66 + --glass-border: rgba(92, 73, 90, 0.1); 67 + --glass-bg: rgba(248, 248, 250, 0.95); 68 + 69 + --text-primary: #18171c; 70 + --text-secondary: #5c495a; 71 + --text-tertiary: #8a8494; 72 + 73 + --border: rgba(92, 73, 90, 0.12); 74 + --border-hover: rgba(92, 73, 90, 0.22); 75 + 76 + --accent: #7a5f6d; 77 + --accent-hover: #664e5b; 78 + --accent-subtle: rgba(149, 122, 134, 0.12); 79 + --accent-text: #5c495a; 80 + 81 + --shadow-sm: 0 1px 3px rgba(92, 73, 90, 0.06); 82 + --shadow-md: 0 4px 12px rgba(92, 73, 90, 0.08); 83 + --shadow-lg: 0 8px 24px rgba(92, 73, 90, 0.1); 84 + --shadow-glow: 0 0 20px rgba(149, 122, 134, 0.1); 85 + 86 + --nav-bg: rgba(255, 255, 255, 0.95); 59 87 } 60 88 61 89 * { ··· 74 102 font-family: var(--font-sans); 75 103 background: var(--bg-primary); 76 104 color: var(--text-primary); 77 - line-height: 1.5; 105 + line-height: 1.55; 78 106 min-height: 100vh; 79 107 -webkit-font-smoothing: antialiased; 80 108 -moz-osx-font-smoothing: grayscale; 81 109 overflow-x: hidden; 82 110 max-width: 100vw; 83 - } 84 - 85 - a { 86 - color: inherit; 87 - text-decoration: none; 88 - transition: color 0.15s ease; 111 + font-size: 0.9375rem; 89 112 } 90 113 91 114 h1, ··· 94 117 h4, 95 118 h5, 96 119 h6 { 120 + font-family: var(--font-display); 97 121 font-weight: 600; 98 - line-height: 1.25; 99 - letter-spacing: -0.025em; 122 + letter-spacing: -0.02em; 100 123 color: var(--text-primary); 124 + line-height: 1.3; 125 + } 126 + 127 + h1 { 128 + font-size: 1.5rem; 129 + } 130 + h2 { 131 + font-size: 1.25rem; 132 + } 133 + h3 { 134 + font-size: 1.1rem; 101 135 } 102 136 103 137 p { 104 138 color: var(--text-secondary); 139 + line-height: 1.6; 140 + } 141 + 142 + a { 143 + color: inherit; 144 + text-decoration: none; 145 + transition: color 0.2s ease; 105 146 } 106 147 107 148 button { ··· 124 165 color: var(--accent-text); 125 166 } 126 167 168 + ::-webkit-scrollbar { 169 + width: 10px; 170 + height: 10px; 171 + } 172 + 173 + ::-webkit-scrollbar-track { 174 + background: var(--bg-secondary); 175 + } 176 + 177 + ::-webkit-scrollbar-thumb { 178 + background: var(--bg-hover); 179 + border-radius: var(--radius-full); 180 + border: 2px solid var(--bg-secondary); 181 + } 182 + 183 + ::-webkit-scrollbar-thumb:hover { 184 + background: var(--text-tertiary); 185 + } 186 + 187 + :focus-visible { 188 + outline: 2px solid var(--accent); 189 + outline-offset: 3px; 190 + } 191 + 127 192 .text-sm { 128 - font-size: 0.875rem; 193 + font-size: 0.9rem; 129 194 } 130 195 131 196 .text-xs { 132 - font-size: 0.75rem; 197 + font-size: 0.8rem; 133 198 } 134 199 135 200 .font-medium { ··· 140 205 font-weight: 600; 141 206 } 142 207 208 + .font-mono { 209 + font-family: var(--font-mono); 210 + } 211 + 143 212 .text-muted { 144 213 color: var(--text-secondary); 145 214 } ··· 148 217 color: var(--text-tertiary); 149 218 } 150 219 151 - ::-webkit-scrollbar { 152 - width: 10px; 153 - height: 10px; 154 - } 155 - 156 - ::-webkit-scrollbar-track { 157 - background: transparent; 158 - } 159 - 160 - ::-webkit-scrollbar-thumb { 161 - background: var(--border); 162 - border-radius: 5px; 163 - border: 2px solid var(--bg-primary); 164 - } 165 - 166 - ::-webkit-scrollbar-thumb:hover { 167 - background: var(--border-hover); 220 + .card { 221 + background: var(--bg-card); 222 + border-radius: var(--radius-lg); 223 + border: 1px solid var(--border); 168 224 }
+39 -25
web/src/css/buttons.css
··· 2 2 display: inline-flex; 3 3 align-items: center; 4 4 justify-content: center; 5 - gap: 8px; 6 - padding: 10px 20px; 7 - font-size: 0.9rem; 5 + gap: 6px; 6 + padding: 8px 16px; 7 + font-size: 0.85rem; 8 8 font-weight: 500; 9 9 border-radius: var(--radius-md); 10 10 transition: all 0.15s ease; 11 - white-space: pre; 11 + white-space: nowrap; 12 + border: none; 13 + cursor: pointer; 12 14 } 13 15 14 16 .btn-primary { ··· 18 20 19 21 .btn-primary:hover { 20 22 background: var(--accent-hover); 21 - transform: translateY(-1px); 22 - box-shadow: var(--shadow-md); 23 + box-shadow: var(--shadow-glow); 23 24 } 24 25 25 26 .btn-secondary { ··· 36 37 .btn-ghost { 37 38 color: var(--text-secondary); 38 39 padding: 8px 12px; 40 + background: transparent; 39 41 } 40 42 41 43 .btn-ghost:hover { ··· 49 51 display: flex; 50 52 align-items: center; 51 53 justify-content: center; 52 - gap: 10px; 53 - transition: 54 - background 0.2s, 55 - transform 0.2s; 54 + gap: 8px; 55 + transition: all 0.15s; 56 56 } 57 57 58 58 .btn-bluesky:hover { 59 59 background: #0070dd; 60 - transform: translateY(-1px); 61 60 } 62 61 63 62 .btn-sm { 64 63 padding: 6px 12px; 65 - font-size: 0.85rem; 64 + font-size: 0.8rem; 66 65 } 67 66 68 67 .btn-text { 69 68 background: none; 70 69 border: none; 71 70 color: var(--text-secondary); 72 - font-size: 0.9rem; 73 - padding: 8px 12px; 71 + font-size: 0.85rem; 72 + padding: 6px 10px; 74 73 cursor: pointer; 75 74 transition: color 0.15s; 75 + border-radius: var(--radius-sm); 76 76 } 77 77 78 78 .btn-text:hover { 79 79 color: var(--text-primary); 80 + background: var(--bg-tertiary); 80 81 } 81 82 82 83 .btn-block { 83 84 width: 100%; 84 85 text-align: left; 85 - padding: 8px 12px; 86 + padding: 10px 14px; 86 87 color: var(--text-secondary); 87 88 background: var(--bg-tertiary); 88 89 border-radius: var(--radius-md); 89 90 margin-top: 8px; 90 - font-size: 0.9rem; 91 + font-size: 0.85rem; 91 92 cursor: pointer; 92 - transition: all 0.2s; 93 + transition: all 0.15s; 94 + border: 1px solid transparent; 93 95 } 94 96 95 97 .btn-block:hover { 96 - background: var(--border); 98 + background: var(--bg-hover); 97 99 color: var(--text-primary); 100 + border-color: var(--border); 98 101 } 99 102 100 103 .btn-icon-danger { 101 104 padding: 8px; 102 - background: var(--error); 103 - color: white; 105 + background: rgba(255, 69, 58, 0.1); 106 + color: var(--error); 104 107 border: none; 105 108 border-radius: var(--radius-md); 106 109 cursor: pointer; 107 - box-shadow: var(--shadow-md); 108 110 transition: all 0.15s ease; 109 111 display: flex; 110 112 align-items: center; ··· 112 114 } 113 115 114 116 .btn-icon-danger:hover { 115 - background: #dc2626; 116 - transform: scale(1.05); 117 + background: var(--error); 118 + color: white; 119 + } 120 + 121 + .btn-danger { 122 + background: rgba(255, 69, 58, 0.1); 123 + color: var(--error); 124 + border: 1px solid rgba(255, 69, 58, 0.2); 125 + } 126 + 127 + .btn-danger:hover { 128 + background: var(--error); 129 + color: white; 130 + border-color: var(--error); 117 131 } 118 132 119 133 .action-buttons { 120 134 display: flex; 121 - gap: 8px; 135 + gap: var(--spacing-sm); 122 136 flex-wrap: wrap; 123 137 } 124 138 125 139 .action-buttons-end { 126 140 display: flex; 127 141 justify-content: flex-end; 128 - gap: 8px; 142 + gap: var(--spacing-sm); 129 143 }
+270
web/src/css/cards.css
··· 1 + .card { 2 + background: var(--bg-primary); 3 + border: none; 4 + border-radius: 0; 5 + transition: all 0.15s ease; 6 + position: relative; 7 + overflow: visible; 8 + } 9 + 10 + .semble-badge { 11 + display: flex; 12 + align-items: center; 13 + gap: 4px; 14 + font-size: 0.75rem; 15 + color: var(--text-tertiary); 16 + margin-right: 4px; 17 + } 18 + 19 + .semble-badge img { 20 + width: 14px; 21 + height: 14px; 22 + } 23 + 24 + .bookmark-preview { 25 + display: block; 26 + padding: 14px 16px; 27 + background: linear-gradient( 28 + 135deg, 29 + var(--bg-tertiary) 0%, 30 + var(--bg-secondary) 100% 31 + ); 32 + border: 1px solid var(--border); 33 + border-left: 3px solid var(--accent); 34 + border-radius: var(--radius-md); 35 + text-decoration: none; 36 + transition: all 0.2s ease; 37 + position: relative; 38 + z-index: 1; 39 + } 40 + 41 + .bookmark-preview:hover { 42 + background: var(--bg-hover); 43 + border-left-color: var(--accent-hover); 44 + } 45 + 46 + .bookmark-preview-content { 47 + display: flex; 48 + flex-direction: column; 49 + gap: 4px; 50 + } 51 + 52 + .bookmark-preview-site { 53 + display: flex; 54 + align-items: center; 55 + gap: 6px; 56 + font-size: 0.7rem; 57 + color: var(--text-tertiary); 58 + text-transform: uppercase; 59 + letter-spacing: 0.06em; 60 + font-weight: 500; 61 + } 62 + 63 + .bookmark-preview-site svg { 64 + color: var(--accent); 65 + } 66 + 67 + .bookmark-preview-title { 68 + font-size: 0.95rem; 69 + font-weight: 600; 70 + color: var(--text-primary); 71 + line-height: 1.35; 72 + margin: 0; 73 + display: -webkit-box; 74 + -webkit-line-clamp: 2; 75 + -webkit-box-orient: vertical; 76 + overflow: hidden; 77 + } 78 + 79 + .bookmark-preview-desc { 80 + font-size: 0.8rem; 81 + color: var(--text-secondary); 82 + line-height: 1.45; 83 + margin: 0; 84 + display: -webkit-box; 85 + -webkit-line-clamp: 2; 86 + -webkit-box-orient: vertical; 87 + overflow: hidden; 88 + } 89 + 90 + .bookmark-card .annotation-content { 91 + padding-left: 0; 92 + overflow: visible; 93 + } 94 + 95 + .bookmark-card { 96 + overflow: visible !important; 97 + } 98 + 99 + .bookmark-card:hover { 100 + z-index: 100 !important; 101 + overflow: visible !important; 102 + } 103 + 104 + .bookmark-site { 105 + display: flex; 106 + align-items: center; 107 + gap: 6px; 108 + font-size: 0.8rem; 109 + color: var(--text-tertiary); 110 + text-transform: uppercase; 111 + letter-spacing: 0.02em; 112 + } 113 + 114 + .bookmark-title { 115 + font-size: 1rem; 116 + font-weight: 600; 117 + color: var(--text-primary); 118 + line-height: 1.4; 119 + margin: 0; 120 + } 121 + 122 + .bookmark-desc { 123 + font-size: 0.875rem; 124 + color: var(--text-secondary); 125 + line-height: 1.5; 126 + margin: 0; 127 + display: -webkit-box; 128 + -webkit-line-clamp: 2; 129 + -webkit-box-orient: vertical; 130 + overflow: hidden; 131 + } 132 + 133 + .edit-form { 134 + display: flex; 135 + flex-direction: column; 136 + gap: 8px; 137 + } 138 + 139 + .edit-textarea, 140 + .edit-input { 141 + width: 100%; 142 + padding: 10px 12px; 143 + background: var(--bg-primary); 144 + border: 1px solid var(--border); 145 + border-radius: var(--radius-md); 146 + color: var(--text-primary); 147 + font-family: inherit; 148 + font-size: 0.9rem; 149 + transition: border-color 0.15s ease; 150 + } 151 + 152 + .edit-textarea { 153 + resize: vertical; 154 + min-height: 80px; 155 + } 156 + 157 + .edit-textarea:focus, 158 + .edit-input:focus { 159 + outline: none; 160 + border-color: var(--accent); 161 + } 162 + 163 + .edit-actions { 164 + display: flex; 165 + justify-content: flex-end; 166 + gap: 8px; 167 + } 168 + 169 + .color-edit-form { 170 + display: flex; 171 + align-items: center; 172 + gap: 8px; 173 + padding: 10px 12px; 174 + background: var(--bg-secondary); 175 + border: 1px solid var(--border); 176 + border-radius: var(--radius-md); 177 + } 178 + 179 + .color-picker-wrapper { 180 + position: relative; 181 + width: 28px; 182 + height: 28px; 183 + flex-shrink: 0; 184 + } 185 + 186 + .color-preview { 187 + width: 100%; 188 + height: 100%; 189 + border-radius: 50%; 190 + border: 2px solid var(--bg-card); 191 + box-shadow: 0 0 0 1px var(--border); 192 + } 193 + 194 + .color-input { 195 + position: absolute; 196 + top: 0; 197 + left: 0; 198 + width: 100%; 199 + height: 100%; 200 + opacity: 0; 201 + cursor: pointer; 202 + } 203 + 204 + .color-edit-form .edit-input { 205 + margin: 0; 206 + flex: 1; 207 + padding: 6px 10px; 208 + height: 32px; 209 + border: none; 210 + background: transparent; 211 + } 212 + 213 + .btn-icon { 214 + padding: 0 10px; 215 + height: 32px; 216 + min-width: auto; 217 + } 218 + 219 + .history-panel { 220 + padding: 12px; 221 + background: var(--bg-secondary); 222 + border: 1px solid var(--border); 223 + border-radius: var(--radius-md); 224 + } 225 + 226 + .history-header { 227 + display: flex; 228 + justify-content: space-between; 229 + align-items: center; 230 + margin-bottom: 12px; 231 + } 232 + 233 + .history-title { 234 + font-size: 0.9rem; 235 + font-weight: 600; 236 + color: var(--text-primary); 237 + } 238 + 239 + .history-status { 240 + font-size: 0.85rem; 241 + color: var(--text-tertiary); 242 + font-style: italic; 243 + } 244 + 245 + .history-list { 246 + list-style: none; 247 + padding: 0; 248 + margin: 0; 249 + display: flex; 250 + flex-direction: column; 251 + gap: 8px; 252 + } 253 + 254 + .history-item { 255 + padding: 8px 10px; 256 + background: var(--bg-tertiary); 257 + border-radius: var(--radius-sm); 258 + } 259 + 260 + .history-date { 261 + font-size: 0.75rem; 262 + color: var(--text-tertiary); 263 + margin-bottom: 4px; 264 + } 265 + 266 + .history-content { 267 + font-size: 0.85rem; 268 + color: var(--text-secondary); 269 + line-height: 1.5; 270 + }
+173 -159
web/src/css/collections.css
··· 1 + .collection-feed-item { 2 + display: flex; 3 + flex-direction: column; 4 + background: var(--bg-primary); 5 + overflow: visible; 6 + } 7 + 8 + .collection-context-badge { 9 + display: flex; 10 + align-items: center; 11 + justify-content: space-between; 12 + gap: var(--spacing-sm); 13 + padding: 10px 20px; 14 + background: var(--bg-secondary); 15 + border-bottom: 1px solid var(--border); 16 + border-top-left-radius: var(--radius-lg); 17 + border-top-right-radius: var(--radius-lg); 18 + } 19 + 20 + .collection-context-inner { 21 + display: flex; 22 + align-items: center; 23 + gap: 8px; 24 + font-size: 0.8rem; 25 + color: var(--text-secondary); 26 + } 27 + 28 + .collection-context-avatar { 29 + width: 20px; 30 + height: 20px; 31 + border-radius: var(--radius-full); 32 + object-fit: cover; 33 + } 34 + 35 + .collection-context-text { 36 + display: flex; 37 + align-items: center; 38 + gap: 4px; 39 + flex-wrap: wrap; 40 + } 41 + 42 + .collection-context-author { 43 + font-weight: 600; 44 + color: var(--text-primary); 45 + text-decoration: none; 46 + } 47 + 48 + .collection-context-author:hover { 49 + text-decoration: underline; 50 + } 51 + 52 + .collection-context-link { 53 + display: inline-flex; 54 + align-items: center; 55 + gap: 5px; 56 + font-weight: 600; 57 + color: var(--accent); 58 + text-decoration: none; 59 + background: var(--accent-subtle); 60 + padding: 2px 8px; 61 + border-radius: var(--radius-sm); 62 + } 63 + 64 + .collection-context-link:hover { 65 + background: var(--accent); 66 + color: var(--bg-primary); 67 + } 68 + 1 69 .collections-list { 2 70 display: flex; 3 71 flex-direction: column; 4 - gap: 2px; 72 + gap: 12px; 73 + } 74 + 75 + .collections-list > * { 5 76 background: var(--bg-card); 6 77 border: 1px solid var(--border); 7 78 border-radius: var(--radius-lg); 8 - overflow: hidden; 9 79 } 10 80 11 81 .collection-row { 12 82 display: flex; 13 83 align-items: center; 14 - background: var(--bg-card); 15 84 transition: background 0.15s ease; 16 85 } 17 86 18 - .collection-row:not(:last-child) { 19 - border-bottom: 1px solid var(--border); 20 - } 21 - 22 87 .collection-row:hover { 23 88 background: var(--bg-secondary); 24 89 } ··· 27 92 flex: 1; 28 93 display: flex; 29 94 align-items: center; 30 - gap: 16px; 31 - padding: 16px 20px; 95 + gap: var(--spacing-md); 96 + padding: var(--spacing-md); 32 97 text-decoration: none; 33 98 min-width: 0; 34 99 } 35 100 36 101 .collection-row-icon { 37 - width: 44px; 38 - height: 44px; 39 - min-width: 44px; 102 + width: 40px; 103 + height: 40px; 104 + min-width: 40px; 40 105 display: flex; 41 106 align-items: center; 42 107 justify-content: center; 43 - background: linear-gradient( 44 - 135deg, 45 - rgba(79, 70, 229, 0.1), 46 - rgba(168, 85, 247, 0.15) 47 - ); 108 + background: var(--bg-tertiary); 48 109 color: var(--accent); 49 110 border-radius: var(--radius-md); 50 - transition: all 0.2s ease; 111 + transition: all 0.15s ease; 112 + font-size: 1.1rem; 51 113 } 52 114 53 115 .collection-row:hover .collection-row-icon { 54 - background: linear-gradient( 55 - 135deg, 56 - rgba(79, 70, 229, 0.15), 57 - rgba(168, 85, 247, 0.2) 58 - ); 59 - transform: scale(1.05); 116 + background: var(--accent-subtle); 60 117 } 61 118 62 119 .collection-row-info { 63 120 flex: 1; 64 121 min-width: 0; 122 + display: flex; 123 + flex-direction: column; 124 + gap: 2px; 65 125 } 66 126 67 127 .collection-row-name { 68 - font-size: 1rem; 128 + font-size: 0.9rem; 69 129 font-weight: 600; 70 130 color: var(--text-primary); 71 - margin: 0 0 2px 0; 72 131 white-space: nowrap; 73 132 overflow: hidden; 74 133 text-overflow: ellipsis; 75 - } 76 - 77 - .collection-row:hover .collection-row-name { 78 - color: var(--accent); 79 134 } 80 135 81 136 .collection-row-desc { 82 - font-size: 0.85rem; 137 + font-size: 0.8rem; 83 138 color: var(--text-secondary); 84 - margin: 0; 85 139 white-space: nowrap; 86 140 overflow: hidden; 87 141 text-overflow: ellipsis; ··· 90 144 .collection-row-arrow { 91 145 color: var(--text-tertiary); 92 146 opacity: 0; 93 - transition: all 0.2s ease; 147 + transition: opacity 0.15s; 94 148 } 95 149 96 150 .collection-row:hover .collection-row-arrow { 97 151 opacity: 1; 98 - color: var(--accent); 99 - transform: translateX(2px); 100 152 } 101 153 102 154 .collection-row-edit { 103 - padding: 10px; 104 - margin-right: 12px; 155 + padding: 8px; 156 + margin-right: var(--spacing-sm); 105 157 color: var(--text-tertiary); 106 - background: none; 158 + background: transparent; 159 + border-radius: var(--radius-sm); 160 + transition: all 0.15s; 161 + opacity: 0; 107 162 border: none; 108 - border-radius: var(--radius-sm); 109 163 cursor: pointer; 110 - opacity: 0; 111 - transition: all 0.15s ease; 112 164 } 113 165 114 166 .collection-row:hover .collection-row-edit { ··· 116 168 } 117 169 118 170 .collection-row-edit:hover { 119 - color: var(--text-primary); 120 171 background: var(--bg-tertiary); 172 + color: var(--text-primary); 121 173 } 122 174 123 175 .collection-detail-header { 124 176 display: flex; 125 - gap: 20px; 126 - padding: 24px; 127 - background: var(--bg-card); 177 + flex-direction: column; 178 + gap: var(--spacing-md); 179 + padding: var(--spacing-lg); 180 + background: var(--bg-secondary); 128 181 border: 1px solid var(--border); 129 182 border-radius: var(--radius-lg); 130 - margin-bottom: 32px; 183 + margin-bottom: var(--spacing-lg); 131 184 position: relative; 132 185 } 133 186 ··· 138 191 display: flex; 139 192 align-items: center; 140 193 justify-content: center; 141 - background: linear-gradient( 142 - 135deg, 143 - rgba(79, 70, 229, 0.1), 144 - rgba(168, 85, 247, 0.1) 145 - ); 194 + background: var(--bg-tertiary); 146 195 color: var(--accent); 147 - border-radius: var(--radius-md); 196 + border-radius: var(--radius-lg); 197 + font-size: 1.5rem; 148 198 } 149 199 150 200 .collection-detail-info { 151 - flex: 1; 152 - min-width: 0; 201 + display: flex; 202 + flex-direction: column; 203 + gap: 6px; 153 204 } 154 205 155 206 .collection-detail-visibility { 156 - display: flex; 207 + display: inline-flex; 157 208 align-items: center; 158 - gap: 6px; 159 - font-size: 0.8rem; 209 + gap: 4px; 210 + font-size: 0.65rem; 160 211 font-weight: 600; 212 + letter-spacing: 0.05em; 213 + text-transform: uppercase; 161 214 color: var(--accent); 162 - text-transform: capitalize; 163 - margin-bottom: 8px; 215 + padding: 2px 8px; 216 + background: var(--accent-subtle); 217 + border-radius: var(--radius-full); 218 + width: fit-content; 164 219 } 165 220 166 221 .collection-detail-title { 222 + font-family: var(--font-display); 167 223 font-size: 1.5rem; 168 224 font-weight: 700; 169 225 color: var(--text-primary); 170 - margin-bottom: 8px; 171 - line-height: 1.3; 172 - } 173 - 174 - @media (max-width: 600px) { 175 - .collection-detail-header { 176 - flex-direction: column; 177 - padding: 16px; 178 - gap: 16px; 179 - } 180 - 181 - .collection-detail-actions { 182 - position: static; 183 - margin-top: -8px; 184 - justify-content: flex-end; 185 - } 226 + line-height: 1.2; 227 + letter-spacing: -0.02em; 186 228 } 187 229 188 230 .collection-detail-desc { 189 231 color: var(--text-secondary); 190 - font-size: 1rem; 232 + font-size: 0.9rem; 191 233 line-height: 1.5; 192 - margin-bottom: 12px; 193 - max-width: 600px; 194 - overflow-wrap: break-word; 195 - word-break: break-word; 196 234 } 197 235 198 236 .collection-detail-stats { 199 237 display: flex; 200 238 align-items: center; 201 - gap: 8px; 202 - font-size: 0.85rem; 239 + gap: var(--spacing-md); 240 + font-size: 0.8rem; 203 241 color: var(--text-tertiary); 242 + margin-top: var(--spacing-xs); 204 243 } 205 244 206 245 .collection-detail-actions { 207 246 position: absolute; 208 - top: 20px; 209 - right: 20px; 247 + top: var(--spacing-md); 248 + right: var(--spacing-md); 210 249 display: flex; 211 - align-items: center; 212 - gap: 8px; 250 + gap: var(--spacing-xs); 213 251 } 214 252 215 - .collection-detail-actions .share-menu-container { 216 - display: flex; 217 - align-items: center; 218 - } 219 - 220 - .collection-detail-actions .annotation-action { 221 - padding: 10px; 222 - color: var(--text-tertiary); 223 - background: none; 224 - border: none; 225 - border-radius: var(--radius-sm); 226 - cursor: pointer; 227 - transition: all 0.15s ease; 228 - } 229 - 230 - .collection-detail-actions .annotation-action:hover { 231 - color: var(--accent); 232 - background: var(--bg-tertiary); 233 - } 234 - 253 + .collection-detail-actions .annotation-action, 235 254 .collection-detail-edit, 236 255 .collection-detail-delete { 237 - padding: 10px; 256 + padding: 6px; 238 257 color: var(--text-tertiary); 239 - background: none; 258 + background: var(--bg-tertiary); 259 + border-radius: var(--radius-sm); 260 + transition: all 0.15s; 240 261 border: none; 241 - border-radius: var(--radius-sm); 242 262 cursor: pointer; 243 - transition: all 0.15s ease; 244 263 } 245 264 265 + .collection-detail-actions .annotation-action:hover, 246 266 .collection-detail-edit:hover { 247 - color: var(--accent); 248 - background: var(--bg-tertiary); 267 + background: var(--bg-hover); 268 + color: var(--text-primary); 249 269 } 250 270 251 271 .collection-detail-delete:hover { 252 - color: var(--error); 253 - background: rgba(239, 68, 68, 0.1); 254 - } 255 - 256 - .collection-item-wrapper { 257 - position: relative; 258 - } 259 - 260 - .collection-item-remove { 261 - position: absolute; 262 - top: 12px; 263 - left: -40px; 264 - z-index: 10; 265 - padding: 8px; 266 - background: var(--bg-card); 267 - border: 1px solid var(--border); 268 - border-radius: var(--radius-sm); 269 - color: var(--text-tertiary); 270 - cursor: pointer; 271 - opacity: 0; 272 - transition: all 0.15s ease; 273 - } 274 - 275 - .collection-item-wrapper:hover .collection-item-remove { 276 - opacity: 1; 277 - } 278 - 279 - .collection-item-remove:hover { 272 + background: rgba(255, 69, 58, 0.1); 280 273 color: var(--error); 281 - border-color: var(--error); 282 - background: rgba(239, 68, 68, 0.05); 283 274 } 284 275 285 276 .collection-list-item { 286 277 width: 100%; 287 278 text-align: left; 288 - padding: 12px 16px; 279 + padding: 12px 14px; 289 280 border-radius: var(--radius-md); 290 - background: var(--bg-primary); 291 - border: 1px solid transparent; 281 + background: var(--bg-secondary); 282 + border: 1px solid var(--border); 292 283 color: var(--text-primary); 293 - transition: all 0.15s ease; 284 + transition: all 0.15s; 294 285 display: flex; 295 286 align-items: center; 296 287 justify-content: space-between; 297 288 cursor: pointer; 289 + margin-bottom: var(--spacing-sm); 298 290 } 299 291 300 292 .collection-list-item:hover { 301 293 background: var(--bg-hover); 302 - border-color: var(--border); 294 + border-color: var(--accent); 303 295 } 304 296 305 - .collection-list-item:hover .collection-list-item-icon { 306 - opacity: 1; 297 + .collection-list-item:disabled { 298 + opacity: 0.5; 299 + cursor: not-allowed; 307 300 } 308 301 309 - .collection-list-item:disabled { 310 - opacity: 0.6; 311 - cursor: not-allowed; 302 + .collection-item-wrapper { 303 + position: relative; 312 304 } 313 305 314 - .item-delete-overlay { 306 + .collection-item-remove { 315 307 position: absolute; 316 - top: 16px; 317 - right: 16px; 318 - z-index: 10; 308 + left: -40px; 309 + top: 20px; 310 + width: 28px; 311 + height: 28px; 312 + display: flex; 313 + align-items: center; 314 + justify-content: center; 315 + background: var(--bg-secondary); 316 + border: 1px solid var(--border); 317 + border-radius: var(--radius-sm); 318 + color: var(--text-tertiary); 319 + cursor: pointer; 320 + transition: all 0.15s ease; 319 321 opacity: 0; 320 - transition: opacity 0.15s ease; 321 322 } 322 323 323 - .card:hover .item-delete-overlay, 324 - div:hover > .item-delete-overlay { 324 + .collection-item-wrapper:hover .collection-item-remove { 325 325 opacity: 1; 326 326 } 327 + 328 + .collection-item-remove:hover { 329 + background: rgba(255, 69, 58, 0.1); 330 + border-color: rgba(255, 69, 58, 0.3); 331 + color: var(--error); 332 + } 333 + 334 + .collection-item-wrapper .card, 335 + .collection-feed-item .card { 336 + background: transparent !important; 337 + border: none !important; 338 + box-shadow: none; 339 + border-radius: 0; 340 + }
+282 -104
web/src/css/feed.css
··· 1 + .feed-container { 2 + /* background: var(--bg-elevated); */ 3 + /* border: 1px solid var(--border-hover); */ 4 + /* border-radius: var(--radius-xl); */ 5 + overflow: visible; 6 + /* padding: 8px; */ 7 + position: relative; 8 + } 9 + 1 10 .feed { 2 11 display: flex; 3 12 flex-direction: column; 4 - gap: 16px; 13 + gap: 12px; 14 + width: 100%; 15 + overflow: visible; 16 + border-radius: var(--radius-lg); 17 + position: relative; 18 + } 19 + 20 + .feed-load-more { 21 + display: inline-flex; 22 + align-items: center; 23 + justify-content: center; 24 + padding: 10px 24px; 25 + background: var(--bg-tertiary); 26 + border: none; 27 + border-radius: var(--radius-md); 28 + color: var(--text-secondary); 29 + font-weight: 500; 30 + font-size: 0.9rem; 31 + cursor: pointer; 32 + transition: all 0.15s ease; 33 + } 34 + 35 + .feed-load-more:hover { 36 + background: var(--bg-hover); 37 + color: var(--text-primary); 38 + } 39 + 40 + .feed-load-more:disabled { 41 + opacity: 0.6; 42 + cursor: not-allowed; 43 + } 44 + 45 + .feed > * { 46 + background: var(--bg-card); 47 + border: 1px solid var(--border); 48 + border-radius: var(--radius-lg); 49 + position: relative; 50 + overflow: hidden; 51 + } 52 + 53 + .feed > *:last-child { 54 + border-bottom: 1px solid var(--border); 55 + } 56 + 57 + .feed > *:hover { 58 + z-index: 10; 59 + } 60 + 61 + .feed-page { 62 + animation: fadeIn 0.3s ease-out; 63 + } 64 + 65 + @keyframes fadeIn { 66 + from { 67 + opacity: 0; 68 + } 69 + 70 + to { 71 + opacity: 1; 72 + } 5 73 } 6 74 7 75 .feed-header { 8 76 display: flex; 9 77 align-items: center; 10 78 justify-content: space-between; 11 - margin-bottom: 8px; 79 + margin-bottom: 20px; 12 80 } 13 81 14 82 .feed-title { 15 - font-size: 1.5rem; 16 - font-weight: 700; 83 + font-family: var(--font-display); 84 + font-size: 1.25rem; 85 + font-weight: 600; 86 + letter-spacing: -0.02em; 17 87 } 18 88 19 89 .feed-filters { 20 90 display: flex; 21 - gap: 8px; 22 - margin-bottom: 24px; 23 - padding: 4px; 24 - background: var(--bg-tertiary); 25 - border-radius: var(--radius-lg); 26 - width: fit-content; 27 - max-width: 100%; 91 + gap: 4px; 92 + margin-bottom: 20px; 93 + background: transparent; 94 + padding: 0; 95 + border: none; 28 96 flex-wrap: wrap; 29 97 } 30 98 31 99 .filter-tab { 32 - padding: 8px 16px; 33 - font-size: 0.9rem; 100 + padding: 8px 14px; 101 + font-size: 0.875rem; 34 102 font-weight: 500; 35 - color: var(--text-secondary); 103 + color: var(--text-tertiary); 36 104 background: transparent; 37 105 border: none; 38 106 border-radius: var(--radius-md); ··· 41 109 } 42 110 43 111 .filter-tab:hover { 44 - color: var(--text-primary); 45 - background: var(--bg-hover); 112 + color: var(--text-secondary); 113 + background: var(--bg-tertiary); 46 114 } 47 115 48 116 .filter-tab.active { 49 117 color: var(--text-primary); 50 - background: var(--bg-card); 51 - box-shadow: var(--shadow-sm); 118 + background: var(--bg-tertiary); 119 + } 120 + 121 + .filter-pill { 122 + padding: 8px 14px; 123 + font-size: 0.8rem; 124 + font-weight: 600; 125 + color: var(--text-secondary); 126 + background: var(--bg-tertiary); 127 + border: none; 128 + border-radius: var(--radius-full); 129 + cursor: pointer; 130 + transition: all 0.15s; 131 + } 132 + 133 + .filter-pill:hover { 134 + background: var(--bg-hover); 135 + color: var(--text-primary); 136 + } 137 + 138 + .filter-pill.active { 139 + background: var(--accent); 140 + color: var(--bg-primary); 52 141 } 53 142 54 143 .page-header { 55 - margin-bottom: 32px; 144 + margin-bottom: 28px; 56 145 } 57 146 58 147 .page-title { 148 + font-family: var(--font-display); 59 149 font-size: 2rem; 60 150 font-weight: 700; 61 151 margin-bottom: 8px; 152 + letter-spacing: -0.02em; 153 + color: var(--text-primary); 62 154 } 63 155 64 156 .page-description { 65 157 color: var(--text-secondary); 66 158 font-size: 1.1rem; 159 + line-height: 1.5; 67 160 } 68 161 69 162 .url-input-wrapper { 70 - margin-bottom: 24px; 163 + margin-bottom: var(--spacing-lg); 164 + position: relative; 71 165 } 72 166 73 167 .url-input-container { 74 168 display: flex; 75 - gap: 12px; 169 + gap: var(--spacing-sm); 76 170 } 77 171 78 172 .url-input { 79 173 width: 100%; 80 - padding: 16px; 174 + padding: 12px 16px; 81 175 background: var(--bg-secondary); 82 176 border: 1px solid var(--border); 83 177 border-radius: var(--radius-md); 84 178 color: var(--text-primary); 85 - font-size: 1.1rem; 86 - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); 87 - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); 179 + font-size: 0.9rem; 180 + transition: all 0.15s ease; 88 181 } 89 182 90 183 .url-input:focus { 91 184 outline: none; 92 185 border-color: var(--accent); 93 - box-shadow: 0 0 0 4px var(--accent-subtle); 94 - background: var(--bg-primary); 186 + box-shadow: 0 0 0 3px var(--accent-subtle); 95 187 } 96 188 97 189 .url-input::placeholder { ··· 102 194 display: flex; 103 195 align-items: center; 104 196 justify-content: space-between; 105 - margin-bottom: 16px; 106 - flex-wrap: wrap; 107 - gap: 12px; 197 + margin-bottom: var(--spacing-md); 108 198 } 109 199 110 200 .back-link { 111 201 display: inline-flex; 112 202 align-items: center; 113 - gap: 8px; 203 + gap: 6px; 114 204 color: var(--text-secondary); 115 - font-size: 0.9rem; 205 + font-size: 0.8rem; 206 + font-weight: 500; 116 207 text-decoration: none; 117 - margin-bottom: 24px; 118 - transition: color 0.15s; 208 + margin-bottom: var(--spacing-lg); 209 + padding: 6px 12px; 210 + background: var(--bg-tertiary); 211 + border-radius: var(--radius-sm); 212 + transition: all 0.15s; 119 213 } 120 214 121 215 .back-link:hover { 122 - color: var(--accent); 123 - } 124 - 125 - .new-page { 126 - max-width: 600px; 127 - margin: 0 auto; 128 - display: flex; 129 - flex-direction: column; 130 - gap: 32px; 131 - } 132 - 133 - @media (max-width: 640px) { 134 - .main-content { 135 - padding: 16px 12px; 136 - } 137 - 138 - .page-title { 139 - font-size: 1.5rem; 140 - } 141 - } 142 - 143 - .user-url-page { 144 - max-width: 800px; 216 + background: var(--bg-hover); 217 + color: var(--text-primary); 145 218 } 146 219 147 220 .url-target-info { 148 221 display: flex; 149 222 flex-direction: column; 150 223 gap: 4px; 151 - padding: 16px; 224 + padding: 12px 16px; 152 225 background: var(--bg-secondary); 153 226 border: 1px solid var(--border); 154 227 border-radius: var(--radius-md); 155 - margin-bottom: 24px; 228 + margin-bottom: var(--spacing-lg); 156 229 } 157 230 158 231 .url-target-label { 159 - font-size: 0.875rem; 160 - color: var(--text-secondary); 232 + font-size: 0.65rem; 233 + text-transform: uppercase; 234 + letter-spacing: 0.05em; 235 + font-weight: 600; 236 + color: var(--text-tertiary); 161 237 } 162 238 163 239 .url-target-link { 164 240 color: var(--accent); 165 - font-size: 0.95rem; 166 - word-break: break-all; 241 + font-size: 0.85rem; 242 + font-weight: 500; 167 243 text-decoration: none; 244 + word-break: break-all; 245 + line-height: 1.4; 168 246 } 169 247 170 248 .url-target-link:hover { ··· 175 253 display: flex; 176 254 align-items: center; 177 255 justify-content: space-between; 178 - gap: 16px; 256 + gap: var(--spacing-md); 179 257 padding: 12px 16px; 180 - background: var(--accent-subtle); 181 - border: 1px solid var(--accent); 258 + background: var(--bg-secondary); 259 + border: 1px solid var(--border); 182 260 border-radius: var(--radius-md); 183 - margin-bottom: 16px; 261 + margin-bottom: var(--spacing-md); 184 262 } 185 263 186 264 .share-notes-info { 187 265 display: flex; 188 266 align-items: center; 189 - gap: 8px; 267 + gap: var(--spacing-sm); 190 268 color: var(--text-primary); 191 - font-size: 0.9rem; 269 + font-size: 0.85rem; 270 + font-weight: 500; 192 271 } 193 272 194 273 .share-notes-actions { 195 274 display: flex; 196 - gap: 8px; 275 + gap: var(--spacing-sm); 276 + } 277 + 278 + .empty-state { 279 + display: flex; 280 + flex-direction: column; 281 + align-items: center; 282 + justify-content: center; 283 + padding: 48px 24px; 284 + text-align: center; 285 + } 286 + 287 + .empty-state-icon { 288 + width: 56px; 289 + height: 56px; 290 + display: flex; 291 + align-items: center; 292 + justify-content: center; 293 + background: var(--bg-tertiary); 294 + border-radius: var(--radius-lg); 295 + color: var(--text-tertiary); 296 + margin-bottom: 16px; 297 + } 298 + 299 + .empty-state-title { 300 + font-size: 1.1rem; 301 + font-weight: 600; 302 + color: var(--text-primary); 303 + margin-bottom: 6px; 304 + } 305 + 306 + .empty-state-text { 307 + font-size: 0.9rem; 308 + color: var(--text-secondary); 309 + max-width: 300px; 310 + line-height: 1.5; 197 311 } 198 312 199 313 @media (max-width: 640px) { 200 - .share-notes-banner { 201 - flex-direction: column; 202 - align-items: stretch; 314 + .feed-filters { 315 + gap: 4px; 203 316 } 204 317 205 - .share-notes-actions { 206 - justify-content: flex-end; 318 + .filter-tab, 319 + .filter-pill { 320 + padding: 6px 10px; 321 + font-size: 0.75rem; 207 322 } 208 323 } 209 324 210 - .feed-tab { 211 - padding: 8px 16px; 212 - font-size: 1rem; 213 - font-weight: 500; 214 - color: var(--text-secondary); 325 + .feed-controls { 326 + display: flex; 327 + flex-direction: column; 328 + gap: var(--spacing-sm); 329 + margin-bottom: var(--spacing-lg); 330 + } 331 + 332 + .active-filter-banner { 333 + display: inline-flex; 334 + align-items: center; 335 + gap: var(--spacing-sm); 336 + padding: 6px 10px 6px 12px; 337 + background: var(--accent-subtle); 338 + border: 1px solid var(--accent); 339 + border-radius: var(--radius-full); 340 + font-size: 0.8rem; 341 + color: var(--accent); 342 + margin-bottom: var(--spacing-md); 343 + width: fit-content; 344 + } 345 + 346 + .active-filter-banner strong { 347 + color: var(--accent-text); 348 + } 349 + 350 + .active-filter-clear { 351 + display: flex; 352 + align-items: center; 353 + justify-content: center; 354 + width: 20px; 355 + height: 20px; 215 356 background: transparent; 216 357 border: none; 217 - border-bottom: 2px solid transparent; 358 + border-radius: var(--radius-full); 359 + color: var(--accent); 218 360 cursor: pointer; 219 - transition: all 0.2s ease; 220 - margin-bottom: -1px; 361 + transition: all 0.15s; 221 362 } 222 363 223 - .feed-tab:hover { 224 - color: var(--text-primary); 364 + .active-filter-clear:hover { 365 + background: var(--accent); 366 + color: white; 225 367 } 226 368 227 - .feed-tab.active { 228 - color: var(--text-primary); 229 - border-bottom-color: var(--text-primary); 230 - font-weight: 600; 369 + .keyboard-hint { 370 + display: none; 371 + align-items: center; 372 + gap: 4px; 373 + font-size: 0.7rem; 374 + color: var(--text-tertiary); 375 + margin-left: auto; 231 376 } 232 377 233 - .filter-pill { 234 - padding: 6px 16px; 235 - font-size: 0.9rem; 236 - font-weight: 500; 378 + @media (min-width: 768px) { 379 + .keyboard-hint { 380 + display: flex; 381 + } 382 + } 383 + 384 + .kbd { 385 + display: inline-flex; 386 + align-items: center; 387 + justify-content: center; 388 + min-width: 20px; 389 + height: 20px; 390 + padding: 0 6px; 391 + background: var(--bg-tertiary); 392 + border: 1px solid var(--border); 393 + border-radius: var(--radius-xs); 394 + font-size: 0.65rem; 395 + font-family: var(--font-mono); 237 396 color: var(--text-secondary); 397 + } 398 + 399 + .back-to-top-btn { 400 + position: fixed; 401 + bottom: 24px; 402 + right: 24px; 403 + width: 44px; 404 + height: 44px; 405 + border-radius: var(--radius-full); 238 406 background: var(--bg-tertiary); 239 - border: 1px solid transparent; 240 - border-radius: 999px; 407 + border: 1px solid var(--border); 408 + color: var(--text-secondary); 409 + display: flex; 410 + align-items: center; 411 + justify-content: center; 241 412 cursor: pointer; 242 - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); 413 + box-shadow: var(--shadow-md); 414 + transition: all 0.2s ease; 415 + z-index: 100; 416 + opacity: 0; 417 + visibility: hidden; 418 + transform: translateY(10px); 243 419 } 244 420 245 - .filter-pill:hover { 246 - background: var(--bg-secondary); 247 - color: var(--text-primary); 248 - border-color: var(--border); 421 + .back-to-top-btn.visible { 422 + opacity: 1; 423 + visibility: visible; 424 + transform: translateY(0); 249 425 } 250 426 251 - .filter-pill.active { 252 - background: var(--text-primary); 253 - color: var(--bg-primary); 254 - font-weight: 600; 427 + .back-to-top-btn:hover { 428 + background: var(--bg-hover); 429 + color: var(--text-primary); 430 + border-color: var(--accent); 431 + transform: translateY(-2px); 432 + box-shadow: var(--shadow-lg); 255 433 }
+925
web/src/css/landing.css
··· 1 + .landing-page { 2 + min-height: 100vh; 3 + background: var(--bg-primary); 4 + } 5 + 6 + .landing-nav { 7 + display: flex; 8 + justify-content: space-between; 9 + align-items: center; 10 + padding: 16px 32px; 11 + max-width: 1200px; 12 + margin: 0 auto; 13 + } 14 + 15 + .landing-logo { 16 + display: flex; 17 + align-items: center; 18 + gap: 10px; 19 + text-decoration: none; 20 + color: var(--text-primary); 21 + font-weight: 600; 22 + font-size: 1.1rem; 23 + } 24 + 25 + .landing-logo img { 26 + width: 28px; 27 + height: 28px; 28 + } 29 + 30 + .landing-nav-links { 31 + display: flex; 32 + align-items: center; 33 + gap: 24px; 34 + } 35 + 36 + .landing-nav-links a:not(.btn) { 37 + color: var(--text-secondary); 38 + text-decoration: none; 39 + font-size: 0.9rem; 40 + transition: color 0.15s; 41 + } 42 + 43 + .landing-nav-links a:not(.btn):hover { 44 + color: var(--text-primary); 45 + } 46 + 47 + .landing-hero { 48 + padding: 80px 32px 40px; 49 + max-width: 800px; 50 + margin: 0 auto; 51 + text-align: center; 52 + } 53 + 54 + .landing-hero-content { 55 + display: flex; 56 + flex-direction: column; 57 + align-items: center; 58 + gap: 24px; 59 + } 60 + 61 + .landing-badge { 62 + display: inline-flex; 63 + align-items: center; 64 + gap: 8px; 65 + font-size: 0.8rem; 66 + font-weight: 500; 67 + color: var(--accent); 68 + background: var(--accent-subtle); 69 + padding: 6px 14px; 70 + border-radius: var(--radius-full); 71 + } 72 + 73 + .landing-title { 74 + font-size: 3.5rem; 75 + font-weight: 700; 76 + line-height: 1.1; 77 + letter-spacing: -0.03em; 78 + color: var(--text-primary); 79 + margin: 0; 80 + } 81 + 82 + .landing-title-accent { 83 + color: var(--accent); 84 + } 85 + 86 + .landing-subtitle { 87 + font-size: 1.2rem; 88 + line-height: 1.7; 89 + color: var(--text-secondary); 90 + max-width: 580px; 91 + margin: 0; 92 + } 93 + 94 + .landing-cta { 95 + display: flex; 96 + gap: 12px; 97 + flex-wrap: wrap; 98 + justify-content: center; 99 + margin-top: 8px; 100 + } 101 + 102 + .btn-lg { 103 + padding: 10px 20px; 104 + font-size: 0.95rem; 105 + } 106 + 107 + .landing-browsers { 108 + font-size: 0.85rem; 109 + color: var(--text-tertiary); 110 + margin: 0; 111 + } 112 + 113 + .landing-browsers a { 114 + color: var(--text-secondary); 115 + text-decoration: underline; 116 + text-underline-offset: 2px; 117 + } 118 + 119 + .landing-browsers a:hover { 120 + color: var(--text-primary); 121 + } 122 + 123 + .landing-demo { 124 + padding: 40px 32px 80px; 125 + max-width: 1100px; 126 + margin: 0 auto; 127 + } 128 + 129 + .demo-window { 130 + background: var(--bg-secondary); 131 + border: 1px solid var(--border); 132 + border-radius: var(--radius-xl); 133 + overflow: hidden; 134 + box-shadow: var(--shadow-lg); 135 + } 136 + 137 + .demo-browser-bar { 138 + display: flex; 139 + align-items: center; 140 + gap: 16px; 141 + padding: 12px 16px; 142 + background: var(--bg-tertiary); 143 + border-bottom: 1px solid var(--border); 144 + } 145 + 146 + .demo-browser-dots { 147 + display: flex; 148 + gap: 6px; 149 + } 150 + 151 + .demo-browser-dots span { 152 + width: 12px; 153 + height: 12px; 154 + border-radius: 50%; 155 + background: var(--border); 156 + } 157 + 158 + .demo-browser-url { 159 + flex: 1; 160 + background: var(--bg-primary); 161 + border-radius: var(--radius-md); 162 + padding: 8px 14px; 163 + font-size: 0.8rem; 164 + color: var(--text-tertiary); 165 + } 166 + 167 + .demo-content { 168 + display: grid; 169 + grid-template-columns: 1fr 340px; 170 + min-height: 380px; 171 + } 172 + 173 + .demo-article { 174 + padding: 32px; 175 + border-right: 1px solid var(--border); 176 + } 177 + 178 + .demo-text { 179 + font-size: 1.05rem; 180 + line-height: 1.9; 181 + color: var(--text-primary); 182 + margin: 0 0 20px 0; 183 + } 184 + 185 + .demo-text:last-child { 186 + margin-bottom: 0; 187 + } 188 + 189 + .demo-highlight { 190 + background-color: transparent; 191 + color: inherit; 192 + border-bottom: 2px solid var(--accent); 193 + } 194 + 195 + .demo-sidebar { 196 + padding: 0; 197 + background: var(--bg-primary); 198 + display: flex; 199 + flex-direction: column; 200 + gap: 0; 201 + overflow-y: auto; 202 + font-family: 203 + "IBM Plex Sans", 204 + -apple-system, 205 + BlinkMacSystemFont, 206 + sans-serif; 207 + } 208 + 209 + .demo-sidebar-header { 210 + display: flex; 211 + align-items: center; 212 + justify-content: space-between; 213 + padding: 14px 16px; 214 + border-bottom: 1px solid var(--border); 215 + background: var(--bg-primary); 216 + } 217 + 218 + .demo-logo-section { 219 + display: flex; 220 + align-items: center; 221 + gap: 10px; 222 + } 223 + 224 + .demo-logo-icon { 225 + color: var(--accent); 226 + display: flex; 227 + align-items: center; 228 + } 229 + 230 + .demo-logo-text { 231 + font-weight: 600; 232 + font-size: 15px; 233 + color: var(--text-primary); 234 + letter-spacing: -0.02em; 235 + } 236 + 237 + .demo-user-section { 238 + display: flex; 239 + align-items: center; 240 + gap: 8px; 241 + } 242 + 243 + .demo-user-handle { 244 + font-size: 12px; 245 + color: var(--text-secondary); 246 + background: var(--bg-tertiary); 247 + padding: 4px 10px; 248 + border-radius: 9999px; 249 + } 250 + 251 + .demo-user-avatar { 252 + width: 24px; 253 + height: 24px; 254 + border-radius: 50%; 255 + background: var(--bg-hover); 256 + color: var(--text-secondary); 257 + display: flex; 258 + align-items: center; 259 + justify-content: center; 260 + font-size: 12px; 261 + font-weight: 600; 262 + } 263 + 264 + .demo-page-info { 265 + display: flex; 266 + align-items: center; 267 + gap: 8px; 268 + padding: 10px 16px; 269 + background: var(--bg-primary); 270 + border-bottom: 1px solid var(--border); 271 + font-size: 12px; 272 + color: var(--text-tertiary); 273 + } 274 + 275 + .demo-annotations-list { 276 + display: flex; 277 + flex-direction: column; 278 + gap: 1px; 279 + background: var(--border); 280 + } 281 + 282 + .demo-annotation { 283 + background: var(--bg-primary); 284 + border: none; 285 + border-radius: 0; 286 + padding: 14px 16px; 287 + } 288 + 289 + .demo-annotation-secondary { 290 + opacity: 1; 291 + } 292 + 293 + .demo-annotation-header { 294 + display: flex; 295 + align-items: center; 296 + gap: 10px; 297 + margin-bottom: 8px; 298 + } 299 + 300 + .demo-avatar { 301 + width: 26px; 302 + height: 26px; 303 + border-radius: 50%; 304 + background: var(--accent); 305 + color: var(--bg-primary); 306 + display: flex; 307 + align-items: center; 308 + justify-content: center; 309 + font-size: 10px; 310 + font-weight: 600; 311 + } 312 + 313 + .demo-meta { 314 + display: flex; 315 + flex-direction: column; 316 + gap: 0; 317 + } 318 + 319 + .demo-author { 320 + font-size: 12px; 321 + font-weight: 600; 322 + color: var(--text-primary); 323 + } 324 + 325 + .demo-time { 326 + font-size: 11px; 327 + color: var(--text-tertiary); 328 + } 329 + 330 + .demo-quote { 331 + font-size: 12px; 332 + font-style: italic; 333 + color: var(--text-secondary); 334 + padding: 8px 12px; 335 + border-left: 2px solid var(--accent); 336 + margin: 0 0 8px 0; 337 + background: var(--accent-subtle); 338 + border-radius: 0 6px 6px 0; 339 + line-height: 1.5; 340 + } 341 + 342 + .demo-comment { 343 + font-size: 13px; 344 + line-height: 1.5; 345 + color: var(--text-primary); 346 + margin: 0 0 12px 0; 347 + } 348 + 349 + .demo-jump-btn { 350 + background: transparent; 351 + border: none; 352 + padding: 0; 353 + color: var(--accent); 354 + font-size: 11px; 355 + font-weight: 500; 356 + cursor: pointer; 357 + display: inline-flex; 358 + align-items: center; 359 + margin-top: 4px; 360 + } 361 + 362 + .demo-jump-btn:hover { 363 + text-decoration: underline; 364 + text-underline-offset: 2px; 365 + } 366 + 367 + .landing-section { 368 + padding: 80px 32px; 369 + max-width: 1000px; 370 + margin: 0 auto; 371 + } 372 + 373 + .landing-section-alt { 374 + background: var(--bg-secondary); 375 + max-width: none; 376 + } 377 + 378 + .landing-section-alt > * { 379 + max-width: 1000px; 380 + margin-left: auto; 381 + margin-right: auto; 382 + } 383 + 384 + .landing-section-title { 385 + font-size: 2rem; 386 + font-weight: 700; 387 + text-align: center; 388 + margin: 0 0 48px 0; 389 + color: var(--text-primary); 390 + } 391 + 392 + .landing-steps { 393 + display: flex; 394 + flex-direction: column; 395 + gap: 32px; 396 + } 397 + 398 + .landing-step { 399 + display: flex; 400 + gap: 24px; 401 + align-items: flex-start; 402 + } 403 + 404 + .landing-step-num { 405 + width: 40px; 406 + height: 40px; 407 + border-radius: 50%; 408 + background: var(--accent); 409 + color: white; 410 + display: flex; 411 + align-items: center; 412 + justify-content: center; 413 + font-weight: 700; 414 + font-size: 1.1rem; 415 + flex-shrink: 0; 416 + } 417 + 418 + .landing-step-content h3 { 419 + font-size: 1.15rem; 420 + font-weight: 600; 421 + margin: 0 0 8px 0; 422 + color: var(--text-primary); 423 + } 424 + 425 + .landing-step-content p { 426 + font-size: 1rem; 427 + color: var(--text-secondary); 428 + margin: 0; 429 + line-height: 1.6; 430 + } 431 + 432 + .landing-features-grid { 433 + display: grid; 434 + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); 435 + gap: 32px; 436 + } 437 + 438 + .landing-feature { 439 + text-align: center; 440 + padding: 24px 16px; 441 + } 442 + 443 + .landing-feature-icon { 444 + width: 52px; 445 + height: 52px; 446 + border-radius: var(--radius-lg); 447 + background: var(--accent-subtle); 448 + color: var(--accent); 449 + display: flex; 450 + align-items: center; 451 + justify-content: center; 452 + margin: 0 auto 16px; 453 + } 454 + 455 + .landing-feature h3 { 456 + font-size: 1.05rem; 457 + font-weight: 600; 458 + margin: 0 0 8px 0; 459 + color: var(--text-primary); 460 + } 461 + 462 + .landing-feature p { 463 + font-size: 0.9rem; 464 + color: var(--text-secondary); 465 + margin: 0; 466 + line-height: 1.6; 467 + } 468 + 469 + .landing-protocol { 470 + background: var(--bg-secondary); 471 + max-width: none; 472 + border-top: 1px solid var(--border); 473 + border-bottom: 1px solid var(--border); 474 + } 475 + 476 + .landing-protocol-grid { 477 + display: grid; 478 + grid-template-columns: 1fr 1fr; 479 + gap: 64px; 480 + align-items: center; 481 + max-width: 1000px; 482 + margin: 0 auto; 483 + } 484 + 485 + .landing-protocol-main h2 { 486 + font-size: 1.75rem; 487 + font-weight: 700; 488 + margin: 0 0 16px 0; 489 + color: var(--text-primary); 490 + } 491 + 492 + .landing-protocol-main p { 493 + font-size: 1rem; 494 + color: var(--text-secondary); 495 + margin: 0 0 16px 0; 496 + line-height: 1.7; 497 + } 498 + 499 + .landing-protocol-main a { 500 + color: var(--accent); 501 + text-decoration: underline; 502 + text-underline-offset: 2px; 503 + } 504 + 505 + .landing-protocol-features { 506 + display: flex; 507 + flex-direction: column; 508 + gap: 20px; 509 + } 510 + 511 + .landing-protocol-item { 512 + display: flex; 513 + gap: 16px; 514 + align-items: flex-start; 515 + color: var(--accent); 516 + } 517 + 518 + .landing-protocol-item div { 519 + display: flex; 520 + flex-direction: column; 521 + } 522 + 523 + .landing-protocol-item strong { 524 + font-size: 0.95rem; 525 + font-weight: 600; 526 + color: var(--text-primary); 527 + } 528 + 529 + .landing-protocol-item span { 530 + font-size: 0.85rem; 531 + color: var(--text-tertiary); 532 + } 533 + 534 + .landing-final-cta { 535 + text-align: center; 536 + } 537 + 538 + .landing-final-cta h2 { 539 + font-size: 2rem; 540 + font-weight: 700; 541 + margin: 0 0 12px 0; 542 + color: var(--text-primary); 543 + } 544 + 545 + .landing-final-cta p { 546 + font-size: 1.1rem; 547 + color: var(--text-secondary); 548 + margin: 0 0 28px 0; 549 + } 550 + 551 + .landing-footer { 552 + border-top: 1px solid var(--border); 553 + padding: 48px 32px 32px; 554 + } 555 + 556 + .landing-footer-grid { 557 + display: flex; 558 + justify-content: space-between; 559 + max-width: 1000px; 560 + margin: 0 auto 40px; 561 + } 562 + 563 + .landing-footer-brand { 564 + max-width: 280px; 565 + } 566 + 567 + .landing-footer-brand p { 568 + font-size: 0.9rem; 569 + color: var(--text-tertiary); 570 + margin: 12px 0 0 0; 571 + } 572 + 573 + .landing-footer-links { 574 + display: flex; 575 + gap: 64px; 576 + } 577 + 578 + .landing-footer-col { 579 + display: flex; 580 + flex-direction: column; 581 + gap: 10px; 582 + } 583 + 584 + .landing-footer-col h4 { 585 + font-size: 0.75rem; 586 + font-weight: 600; 587 + text-transform: uppercase; 588 + letter-spacing: 0.08em; 589 + color: var(--text-tertiary); 590 + margin: 0 0 4px 0; 591 + } 592 + 593 + .landing-footer-col a { 594 + font-size: 0.9rem; 595 + color: var(--text-secondary); 596 + text-decoration: none; 597 + } 598 + 599 + .landing-footer-col a:hover { 600 + color: var(--text-primary); 601 + } 602 + 603 + .landing-footer-bottom { 604 + text-align: center; 605 + padding-top: 24px; 606 + border-top: 1px solid var(--border); 607 + max-width: 1000px; 608 + margin: 0 auto; 609 + } 610 + 611 + .landing-footer-bottom p { 612 + font-size: 0.85rem; 613 + color: var(--text-tertiary); 614 + margin: 0; 615 + } 616 + 617 + @media (max-width: 900px) { 618 + .demo-content { 619 + grid-template-columns: 1fr; 620 + } 621 + 622 + .demo-article { 623 + border-right: none; 624 + border-bottom: 1px solid var(--border); 625 + } 626 + 627 + .demo-sidebar { 628 + max-height: 340px; 629 + } 630 + 631 + .landing-protocol-grid { 632 + grid-template-columns: 1fr; 633 + gap: 40px; 634 + } 635 + } 636 + 637 + @media (max-width: 768px) { 638 + .landing-nav { 639 + padding: 16px 20px; 640 + } 641 + 642 + .landing-nav-links a:not(.btn) { 643 + display: none; 644 + } 645 + 646 + .landing-hero { 647 + padding: 60px 20px 30px; 648 + } 649 + 650 + .landing-title { 651 + font-size: 2.5rem; 652 + } 653 + 654 + .landing-subtitle { 655 + font-size: 1.1rem; 656 + } 657 + 658 + .landing-cta { 659 + flex-direction: column; 660 + width: 100%; 661 + } 662 + 663 + .landing-cta .btn { 664 + width: 100%; 665 + justify-content: center; 666 + } 667 + 668 + .landing-demo { 669 + padding: 30px 16px 60px; 670 + } 671 + 672 + .demo-browser-bar { 673 + padding: 10px 12px; 674 + } 675 + 676 + .demo-browser-dots { 677 + display: none; 678 + } 679 + 680 + .demo-article { 681 + padding: 20px; 682 + } 683 + 684 + .demo-text { 685 + font-size: 0.95rem; 686 + } 687 + 688 + .demo-sidebar { 689 + padding: 16px; 690 + } 691 + 692 + .landing-section { 693 + padding: 60px 20px; 694 + } 695 + 696 + .landing-section-title { 697 + font-size: 1.5rem; 698 + margin-bottom: 32px; 699 + } 700 + 701 + .landing-step { 702 + gap: 16px; 703 + } 704 + 705 + .landing-step-num { 706 + width: 32px; 707 + height: 32px; 708 + font-size: 0.95rem; 709 + } 710 + 711 + .landing-features-grid { 712 + grid-template-columns: 1fr; 713 + gap: 24px; 714 + } 715 + 716 + .landing-feature { 717 + text-align: left; 718 + display: flex; 719 + gap: 16px; 720 + padding: 16px 0; 721 + } 722 + 723 + .landing-feature-icon { 724 + margin: 0; 725 + width: 44px; 726 + height: 44px; 727 + flex-shrink: 0; 728 + } 729 + 730 + .landing-protocol-main h2 { 731 + font-size: 1.5rem; 732 + } 733 + 734 + .landing-footer { 735 + padding: 40px 20px 24px; 736 + } 737 + 738 + .landing-footer-grid { 739 + flex-direction: column; 740 + gap: 40px; 741 + } 742 + 743 + .landing-footer-links { 744 + flex-wrap: wrap; 745 + gap: 32px; 746 + } 747 + } 748 + 749 + .demo-hover-indicator { 750 + position: absolute; 751 + display: flex; 752 + align-items: center; 753 + z-index: 100; 754 + pointer-events: none; 755 + background: transparent; 756 + opacity: 0; 757 + transform: scale(0.8); 758 + transition: 759 + opacity 0.15s ease-out, 760 + transform 0.15s ease-out; 761 + } 762 + 763 + .demo-hover-indicator.visible { 764 + opacity: 1; 765 + transform: scale(1); 766 + } 767 + 768 + .demo-hover-avatar { 769 + width: 28px; 770 + height: 28px; 771 + border-radius: 50%; 772 + object-fit: cover; 773 + border: 2px solid var(--bg-primary); 774 + margin-left: -10px; 775 + background: var(--bg-elevated); 776 + } 777 + 778 + .demo-hover-avatar:first-child { 779 + margin-left: 0; 780 + } 781 + 782 + .demo-hover-avatar-fallback { 783 + width: 28px; 784 + height: 28px; 785 + border-radius: 50%; 786 + background: #6366f1; 787 + color: white; 788 + display: flex; 789 + align-items: center; 790 + justify-content: center; 791 + font-size: 12px; 792 + font-weight: 600; 793 + font-family: -apple-system, sans-serif; 794 + border: 2px solid var(--bg-primary); 795 + margin-left: -10px; 796 + } 797 + 798 + .demo-hover-avatar-fallback:first-child { 799 + margin-left: 0; 800 + } 801 + 802 + @keyframes demo-popover-in { 803 + from { 804 + opacity: 0; 805 + transform: translateY(-4px); 806 + } 807 + 808 + to { 809 + opacity: 1; 810 + transform: translateY(0); 811 + } 812 + } 813 + 814 + .demo-popover { 815 + position: absolute; 816 + width: 300px; 817 + background: var(--bg-card); 818 + border: 1px solid var(--border); 819 + border-radius: 12px; 820 + padding: 0; 821 + box-shadow: var(--shadow-lg); 822 + display: flex; 823 + flex-direction: column; 824 + z-index: 200; 825 + font-family: inherit; 826 + color: var(--text-primary); 827 + opacity: 0; 828 + animation: demo-popover-in 0.15s forwards; 829 + max-height: 400px; 830 + overflow: hidden; 831 + } 832 + 833 + .demo-popover-header { 834 + padding: 10px 14px; 835 + border-bottom: 1px solid var(--border); 836 + display: flex; 837 + justify-content: space-between; 838 + align-items: center; 839 + background: var(--bg-primary); 840 + border-radius: 12px 12px 0 0; 841 + font-weight: 500; 842 + font-size: 11px; 843 + color: var(--text-tertiary); 844 + text-transform: uppercase; 845 + letter-spacing: 0.5px; 846 + } 847 + 848 + .demo-popover-close { 849 + background: none; 850 + border: none; 851 + color: var(--text-tertiary); 852 + cursor: pointer; 853 + padding: 2px; 854 + font-size: 16px; 855 + line-height: 1; 856 + opacity: 0.6; 857 + transition: opacity 0.15s; 858 + } 859 + 860 + .demo-popover-close:hover { 861 + opacity: 1; 862 + } 863 + 864 + .demo-popover-scroll-area { 865 + overflow-y: auto; 866 + max-height: 340px; 867 + } 868 + 869 + .demo-comment-item { 870 + padding: 12px 14px; 871 + border-bottom: 1px solid var(--border); 872 + } 873 + 874 + .demo-comment-item:last-child { 875 + border-bottom: none; 876 + } 877 + 878 + .demo-comment-header { 879 + display: flex; 880 + align-items: center; 881 + gap: 8px; 882 + margin-bottom: 6px; 883 + } 884 + 885 + .demo-comment-avatar { 886 + width: 22px; 887 + height: 22px; 888 + border-radius: 50%; 889 + object-fit: cover; 890 + background: var(--accent); 891 + } 892 + 893 + .demo-comment-handle { 894 + font-size: 12px; 895 + font-weight: 600; 896 + color: var(--text-primary); 897 + } 898 + 899 + .demo-comment-text { 900 + font-size: 13px; 901 + line-height: 1.5; 902 + color: var(--text-primary); 903 + margin-bottom: 8px; 904 + } 905 + 906 + .demo-comment-actions { 907 + display: flex; 908 + gap: 8px; 909 + } 910 + 911 + .demo-comment-action-btn { 912 + background: none; 913 + border: none; 914 + padding: 4px 8px; 915 + color: var(--text-tertiary); 916 + font-size: 11px; 917 + cursor: pointer; 918 + border-radius: 4px; 919 + transition: all 0.15s; 920 + } 921 + 922 + .demo-comment-action-btn:hover { 923 + background: var(--bg-hover); 924 + color: var(--text-secondary); 925 + }
+310 -342
web/src/css/layout.css
··· 1 - .layout { 2 - display: flex; 1 + .app { 3 2 min-height: 100vh; 4 3 background: var(--bg-primary); 5 4 } 6 5 7 - .sidebar { 8 - position: fixed; 9 - left: 0; 6 + .top-nav { 7 + position: sticky; 10 8 top: 0; 11 - bottom: 0; 12 - width: 240px; 13 - background: var(--bg-primary); 14 - border-right: 1px solid var(--border); 9 + z-index: 100; 10 + background: var(--nav-bg); 11 + backdrop-filter: blur(12px); 12 + -webkit-backdrop-filter: blur(12px); 13 + border-bottom: 1px solid var(--border); 14 + } 15 + 16 + .top-nav-inner { 17 + max-width: 1200px; 18 + margin: 0 auto; 19 + padding: 0 32px; 20 + height: 56px; 15 21 display: flex; 16 - flex-direction: column; 17 - z-index: 50; 18 - padding-bottom: 20px; 22 + align-items: center; 23 + gap: 32px; 19 24 } 20 25 21 - .sidebar-header { 22 - height: 64px; 26 + .top-nav-logo { 23 27 display: flex; 24 28 align-items: center; 25 - padding: 0 20px; 26 - margin-bottom: 12px; 29 + gap: 10px; 27 30 text-decoration: none; 28 31 color: var(--text-primary); 29 - } 30 - 31 - .sidebar-logo { 32 - width: 24px; 33 - height: 24px; 34 - object-fit: contain; 35 - margin-right: 12px; 32 + font-weight: 700; 33 + font-size: 1.1rem; 34 + flex-shrink: 0; 36 35 } 37 36 38 - .sidebar-brand { 39 - font-size: 1rem; 40 - font-weight: 600; 41 - color: var(--text-primary); 42 - letter-spacing: -0.01em; 37 + .top-nav-logo img { 38 + width: 26px; 39 + height: 26px; 43 40 } 44 41 45 - .sidebar-nav { 46 - flex: 1; 42 + .top-nav-links { 47 43 display: flex; 48 - flex-direction: column; 44 + align-items: center; 49 45 gap: 4px; 50 - padding: 0 12px; 51 - overflow-y: auto; 46 + flex: 1; 52 47 } 53 48 54 - .sidebar-link { 55 - display: flex; 56 - align-items: center; 57 - gap: 12px; 58 - padding: 8px 12px; 59 - border-radius: var(--radius-md); 49 + .top-nav-link { 50 + padding: 8px 14px; 60 51 color: var(--text-secondary); 61 52 text-decoration: none; 62 53 font-size: 0.9rem; 63 54 font-weight: 500; 64 - transition: all 0.15s ease; 55 + border-radius: var(--radius-md); 56 + transition: all 0.15s; 65 57 } 66 58 67 - .sidebar-link:hover { 68 - background: var(--bg-tertiary); 59 + .top-nav-link:hover { 69 60 color: var(--text-primary); 61 + background: var(--bg-hover); 70 62 } 71 63 72 - .sidebar-link.active { 64 + .top-nav-link.active { 65 + color: var(--text-primary); 73 66 background: var(--bg-tertiary); 74 - color: var(--text-primary); 75 67 } 76 68 77 - .sidebar-link svg { 78 - width: 18px; 79 - height: 18px; 80 - color: var(--text-tertiary); 81 - transition: color 0.15s ease; 69 + .top-nav-link.extension-link { 70 + display: flex; 71 + align-items: center; 72 + gap: 6px; 82 73 } 83 74 84 - .sidebar-link:hover svg, 85 - .sidebar-link.active svg { 86 - color: var(--text-primary); 87 - } 88 - 89 - .sidebar-section-title { 90 - padding: 24px 12px 8px; 91 - font-size: 0.75rem; 92 - font-weight: 600; 93 - color: var(--text-tertiary); 94 - text-transform: uppercase; 95 - letter-spacing: 0.05em; 96 - } 97 - 98 - .notification-badge { 99 - background: var(--accent); 100 - color: white; 101 - font-size: 0.7rem; 102 - font-weight: 600; 103 - padding: 0 6px; 104 - height: 18px; 105 - border-radius: 99px; 75 + .top-nav-actions { 106 76 display: flex; 107 77 align-items: center; 108 - justify-content: center; 109 - margin-left: auto; 78 + gap: 8px; 110 79 } 111 80 112 - .sidebar-new-btn { 81 + .top-nav-icon-btn { 113 82 display: flex; 114 83 align-items: center; 115 - gap: 10px; 116 - margin: 0 12px 16px; 117 - padding: 10px 16px; 118 - background: var(--text-primary); 119 - color: var(--bg-primary); 84 + justify-content: center; 85 + width: 36px; 86 + height: 36px; 120 87 border-radius: var(--radius-md); 121 - font-size: 0.9rem; 122 - font-weight: 600; 88 + background: transparent; 89 + border: none; 90 + color: var(--text-secondary); 91 + cursor: pointer; 92 + transition: all 0.15s; 93 + position: relative; 123 94 text-decoration: none; 124 - transition: opacity 0.15s; 125 - justify-content: center; 126 95 } 127 96 128 - .sidebar-new-btn:hover { 129 - opacity: 0.9; 97 + .top-nav-icon-btn:hover { 98 + background: var(--bg-hover); 99 + color: var(--text-primary); 130 100 } 131 101 132 - .sidebar-footer { 133 - padding: 0 12px; 134 - margin-top: auto; 102 + .notif-dot { 103 + position: absolute; 104 + top: 6px; 105 + right: 6px; 106 + width: 8px; 107 + height: 8px; 108 + background: var(--accent); 109 + border-radius: 50%; 110 + border: 2px solid var(--bg-primary); 135 111 } 136 112 137 - .sidebar-user { 113 + .top-nav-new-btn { 138 114 display: flex; 139 115 align-items: center; 140 - gap: 10px; 141 - padding: 8px 12px; 116 + gap: 6px; 117 + padding: 8px 16px; 118 + background: var(--accent); 119 + color: var(--bg-primary); 142 120 border-radius: var(--radius-md); 143 - cursor: pointer; 144 - transition: background 0.15s ease; 121 + font-size: 0.875rem; 122 + font-weight: 600; 123 + text-decoration: none; 124 + transition: all 0.15s; 145 125 } 146 126 147 - .sidebar-user:hover, 148 - .sidebar-user.active { 149 - background: var(--bg-tertiary); 127 + .top-nav-new-btn:hover { 128 + background: var(--accent-hover); 150 129 } 151 130 152 - .sidebar-avatar { 153 - width: 32px; 154 - height: 32px; 155 - border-radius: 50%; 131 + .top-nav-avatar { 132 + width: 34px; 133 + height: 34px; 134 + border-radius: var(--radius-md); 156 135 background: var(--bg-tertiary); 136 + border: none; 137 + cursor: pointer; 138 + overflow: hidden; 157 139 display: flex; 158 140 align-items: center; 159 141 justify-content: center; 160 142 color: var(--text-secondary); 161 143 font-size: 0.8rem; 162 - font-weight: 500; 163 - overflow: hidden; 164 - flex-shrink: 0; 165 - border: 1px solid var(--border); 144 + font-weight: 600; 145 + transition: opacity 0.15s; 166 146 } 167 147 168 - .sidebar-avatar img { 148 + .top-nav-avatar:hover { 149 + opacity: 0.85; 150 + } 151 + 152 + .top-nav-avatar img { 169 153 width: 100%; 170 154 height: 100%; 171 155 object-fit: cover; 172 156 } 173 157 174 - .sidebar-user-info { 175 - flex: 1; 176 - min-width: 0; 177 - display: flex; 178 - flex-direction: column; 179 - } 180 - 181 - .sidebar-user-name { 182 - font-size: 0.85rem; 183 - font-weight: 500; 158 + .top-nav-mobile-toggle { 159 + display: none; 160 + align-items: center; 161 + justify-content: center; 162 + width: 40px; 163 + height: 40px; 164 + border: none; 165 + background: transparent; 184 166 color: var(--text-primary); 167 + cursor: pointer; 185 168 } 186 169 187 - .sidebar-user-handle { 188 - font-size: 0.75rem; 189 - color: var(--text-tertiary); 170 + .top-nav-dropdown { 171 + position: relative; 190 172 } 191 173 192 - .sidebar-dropdown { 174 + .dropdown-menu { 193 175 position: absolute; 194 - bottom: 74px; 195 - left: 12px; 196 - width: 216px; 197 - background: var(--bg-card); 176 + top: calc(100% + 8px); 177 + min-width: 200px; 178 + background: var(--bg-elevated); 198 179 border: 1px solid var(--border); 199 - border-radius: var(--radius-md); 180 + border-radius: var(--radius-lg); 181 + padding: 6px; 200 182 box-shadow: var(--shadow-lg); 201 - padding: 4px; 202 - z-index: 1000; 203 - overflow: hidden; 204 - animation: scaleIn 0.1s ease-out; 205 - transform-origin: bottom center; 183 + z-index: 200; 206 184 } 207 185 208 - @keyframes scaleIn { 209 - from { 210 - opacity: 0; 211 - transform: scale(0.95); 212 - } 213 - 214 - to { 215 - opacity: 1; 216 - transform: scale(1); 217 - } 186 + .dropdown-right { 187 + right: 0; 218 188 } 219 189 220 - .sidebar-dropdown-item { 190 + .dropdown-item { 221 191 display: flex; 222 192 align-items: center; 223 193 gap: 10px; 224 194 width: 100%; 225 - padding: 8px 12px; 226 - font-size: 0.85rem; 195 + padding: 10px 12px; 196 + border-radius: var(--radius-md); 227 197 color: var(--text-secondary); 198 + font-size: 0.875rem; 199 + font-weight: 500; 228 200 text-decoration: none; 229 - background: transparent; 230 - cursor: pointer; 231 - border-radius: var(--radius-sm); 232 201 transition: all 0.15s; 202 + background: none; 233 203 border: none; 204 + cursor: pointer; 205 + text-align: left; 234 206 } 235 207 236 - .sidebar-dropdown-item:hover { 237 - background: var(--bg-tertiary); 208 + .dropdown-item:hover { 209 + background: var(--bg-hover); 238 210 color: var(--text-primary); 239 211 } 240 212 241 - .sidebar-dropdown-item.danger:hover { 242 - background: rgba(239, 68, 68, 0.1); 213 + .dropdown-item.danger:hover { 214 + background: rgba(217, 119, 102, 0.12); 243 215 color: var(--error); 244 216 } 245 217 246 - .main-layout { 247 - flex: 1; 248 - margin-left: 240px; 249 - margin-right: 280px; 250 - min-height: 100vh; 218 + .dropdown-external { 219 + margin-left: auto; 220 + opacity: 0.4; 251 221 } 252 222 253 - .main-content-wrapper { 254 - max-width: 640px; 255 - margin: 0 auto; 256 - padding: 40px 24px; 257 - } 258 - 259 - .right-sidebar { 260 - position: fixed; 261 - right: 0; 262 - top: 0; 263 - bottom: 0; 264 - width: 280px; 265 - background: var(--bg-primary); 266 - border-left: 1px solid var(--border); 267 - padding: 32px 24px; 268 - overflow-y: auto; 223 + .tangled-icon-wrapper { 224 + width: 16px; 225 + height: 16px; 269 226 display: flex; 270 - flex-direction: column; 271 - gap: 32px; 227 + align-items: center; 228 + justify-content: center; 272 229 } 273 230 274 - .right-section { 275 - display: flex; 276 - flex-direction: column; 277 - gap: 12px; 231 + .tangled-icon-wrapper img { 232 + width: 16px; 233 + height: 16px; 234 + filter: grayscale(100%) brightness(1.5); 235 + opacity: 0.6; 236 + transition: all 0.15s; 278 237 } 279 238 280 - .right-section-title { 281 - font-size: 0.75rem; 282 - font-weight: 600; 283 - color: var(--text-primary); 284 - margin-bottom: 4px; 239 + .dropdown-item:hover .tangled-icon-wrapper img { 240 + opacity: 0.9; 285 241 } 286 242 287 - .right-section-desc { 288 - font-size: 0.85rem; 289 - line-height: 1.5; 290 - color: var(--text-secondary); 243 + [data-theme="light"] .tangled-icon-wrapper img { 244 + filter: grayscale(100%) brightness(0) invert(0.35); 245 + opacity: 1; 291 246 } 292 247 293 - .right-extension-btn { 294 - display: inline-flex; 295 - align-items: center; 296 - gap: 8px; 297 - padding: 8px 12px; 298 - background: var(--bg-primary); 299 - border: 1px solid var(--border); 300 - border-radius: var(--radius-md); 301 - color: var(--text-primary); 302 - font-size: 0.85rem; 303 - font-weight: 500; 304 - text-decoration: none; 305 - transition: all 0.15s ease; 306 - width: fit-content; 248 + [data-theme="light"] .dropdown-item:hover .tangled-icon-wrapper img { 249 + filter: grayscale(100%) brightness(0) invert(0.1); 250 + opacity: 1; 307 251 } 308 252 309 - .right-extension-btn:hover { 310 - border-color: var(--text-tertiary); 311 - background: var(--bg-tertiary); 253 + .dropdown-divider { 254 + height: 1px; 255 + background: var(--border); 256 + margin: 6px 0; 312 257 } 313 258 314 - .right-links { 259 + .dropdown-user-info { 260 + padding: 8px 12px; 315 261 display: flex; 316 262 flex-direction: column; 317 - gap: 4px; 318 - } 319 - 320 - .right-link { 321 - display: flex; 322 - align-items: center; 323 - justify-content: space-between; 324 - padding: 6px 0; 325 - color: var(--text-secondary); 326 - font-size: 0.9rem; 327 - transition: color 0.15s; 328 - text-decoration: none; 263 + gap: 2px; 329 264 } 330 265 331 - .right-link:hover { 266 + .dropdown-user-name { 267 + font-weight: 600; 332 268 color: var(--text-primary); 269 + font-size: 0.9rem; 333 270 } 334 271 335 - .right-link svg { 336 - width: 16px; 337 - height: 16px; 272 + .dropdown-user-handle { 338 273 color: var(--text-tertiary); 339 - transition: all 0.15s; 274 + font-size: 0.8rem; 340 275 } 341 276 342 - .right-link:hover svg { 343 - color: var(--text-secondary); 277 + .main-content { 278 + max-width: 1300px; 279 + margin: 0 auto; 280 + padding: 32px 56px 80px; 344 281 } 345 282 346 - .tangled-icon { 347 - width: 16px; 348 - height: 16px; 349 - background-color: var(--text-tertiary); 350 - -webkit-mask: url("../assets/tangled.svg") no-repeat center / contain; 351 - mask: url("../assets/tangled.svg") no-repeat center / contain; 352 - transition: background-color 0.15s; 353 - } 354 - 355 - .right-link:hover .tangled-icon { 356 - background-color: var(--text-secondary); 283 + .mobile-menu { 284 + display: none; 285 + position: absolute; 286 + top: 100%; 287 + left: 0; 288 + right: 0; 289 + background: var(--bg-secondary); 290 + border-bottom: 1px solid var(--border); 291 + padding: 12px 16px; 357 292 } 358 293 359 - .right-footer { 360 - margin-top: auto; 294 + .mobile-menu-link { 361 295 display: flex; 362 296 align-items: center; 363 - justify-content: space-between; 364 - padding-top: 16px; 365 - border-top: 1px solid var(--border); 297 + gap: 12px; 298 + padding: 12px 16px; 299 + color: var(--text-secondary); 300 + text-decoration: none; 301 + font-size: 0.95rem; 302 + font-weight: 500; 303 + border-radius: var(--radius-md); 304 + transition: all 0.15s; 366 305 } 367 306 368 - .footer-links { 369 - display: flex; 370 - align-items: center; 371 - gap: 8px; 372 - font-size: 12px; 373 - color: var(--text-tertiary); 307 + .mobile-menu-link:hover, 308 + .mobile-menu-link.active { 309 + background: var(--bg-hover); 310 + color: var(--text-primary); 374 311 } 375 312 376 - .footer-links a { 377 - color: var(--text-tertiary); 378 - text-decoration: none; 313 + .mobile-menu-link.active { 314 + color: var(--accent); 379 315 } 380 316 381 - .footer-links a:hover { 382 - text-decoration: underline; 383 - color: var(--text-secondary); 317 + .mobile-menu-divider { 318 + height: 1px; 319 + background: var(--border); 320 + margin: 8px 0; 384 321 } 385 322 386 - .theme-toggle-mini { 387 - background: none; 388 - border: none; 389 - cursor: pointer; 390 - padding: 4px; 391 - color: var(--text-tertiary); 392 - display: flex; 393 - align-items: center; 394 - justify-content: center; 395 - border-radius: 4px; 396 - transition: all 0.2s; 323 + .notification-badge { 324 + background: var(--accent); 325 + color: var(--bg-primary); 326 + font-size: 0.7rem; 327 + font-weight: 700; 328 + padding: 2px 6px; 329 + border-radius: var(--radius-full); 330 + margin-left: auto; 397 331 } 398 332 399 - .theme-toggle-mini:hover { 400 - color: var(--text-primary); 401 - background: var(--bg-hover); 402 - } 403 - 404 - .mobile-nav { 333 + .mobile-bottom-nav { 405 334 display: none; 406 335 position: fixed; 407 336 bottom: 0; ··· 411 340 backdrop-filter: blur(12px); 412 341 -webkit-backdrop-filter: blur(12px); 413 342 border-top: 1px solid var(--border); 414 - padding: 8px 16px; 415 - padding-bottom: calc(8px + env(safe-area-inset-bottom, 0)); 343 + padding: 8px 8px calc(8px + env(safe-area-inset-bottom)); 416 344 z-index: 100; 417 345 } 418 346 419 - .mobile-nav-inner { 420 - display: flex; 421 - justify-content: space-between; 347 + .mobile-bottom-nav { 348 + display: none; 349 + justify-content: space-around; 422 350 align-items: center; 423 351 } 424 352 425 - .mobile-nav-item { 353 + .mobile-bottom-nav-item { 426 354 display: flex; 427 355 flex-direction: column; 428 356 align-items: center; 429 - justify-content: center; 430 357 gap: 4px; 358 + padding: 6px 12px; 431 359 color: var(--text-tertiary); 432 360 text-decoration: none; 433 361 font-size: 0.65rem; 434 362 font-weight: 500; 435 - width: 60px; 436 363 transition: color 0.15s; 364 + min-width: 56px; 437 365 } 438 366 439 - .mobile-nav-item.active { 440 - color: var(--text-primary); 367 + .mobile-bottom-nav-item.active { 368 + color: var(--accent); 441 369 } 442 370 443 - .mobile-nav-item svg { 371 + .mobile-bottom-nav-item:active { 372 + transform: scale(0.95); 373 + } 374 + 375 + .mobile-bottom-nav-new { 376 + padding: 6px 16px; 377 + } 378 + 379 + .mobile-nav-new-btn { 380 + display: flex; 381 + align-items: center; 382 + justify-content: center; 383 + width: 44px; 384 + height: 44px; 385 + background: var(--accent); 386 + color: var(--bg-primary); 387 + border-radius: var(--radius-full); 388 + box-shadow: var(--shadow-md); 389 + } 390 + 391 + .mobile-nav-avatar { 444 392 width: 24px; 445 393 height: 24px; 394 + border-radius: var(--radius-full); 395 + object-fit: cover; 446 396 } 447 397 448 - .mobile-nav-new { 449 - width: 48px; 450 - height: 36px; 451 - border-radius: var(--radius-md); 452 - background: var(--text-primary); 453 - color: var(--bg-primary); 398 + .ios-shortcut-banner { 399 + display: none; 400 + position: relative; 401 + padding: 20px; 402 + margin-bottom: 12px; 403 + text-align: center; 404 + } 405 + 406 + .ios-shortcut-banner-close { 407 + position: absolute; 408 + top: 8px; 409 + right: 8px; 410 + background: none; 411 + border: none; 412 + color: var(--text-tertiary); 413 + cursor: pointer; 414 + padding: 6px; 454 415 display: flex; 455 416 align-items: center; 456 417 justify-content: center; 418 + opacity: 0.5; 419 + transition: opacity 0.15s; 457 420 } 458 421 459 - .mobile-nav-new svg { 460 - width: 20px; 461 - height: 20px; 422 + .ios-shortcut-banner-close:hover { 423 + opacity: 1; 424 + } 425 + 426 + .ios-shortcut-banner-content { 427 + display: flex; 428 + flex-direction: column; 429 + align-items: center; 430 + gap: 12px; 431 + } 432 + 433 + .ios-shortcut-banner-icon { 434 + display: none; 462 435 } 463 436 464 - @media (max-width: 1200px) { 465 - .right-sidebar { 466 - display: none; 467 - } 437 + .ios-shortcut-banner-text { 438 + text-align: center; 439 + } 468 440 469 - .main-layout { 470 - margin-right: 0; 471 - } 441 + .ios-shortcut-banner-text strong { 442 + display: none; 472 443 } 473 444 474 - @media (max-width: 768px) { 475 - .sidebar { 476 - display: none; 477 - } 445 + .ios-shortcut-banner-text p { 446 + font-size: 0.8rem; 447 + color: var(--text-tertiary); 448 + margin: 0; 449 + line-height: 1.4; 450 + } 478 451 479 - .main-layout { 480 - margin-left: 0; 481 - padding-bottom: 80px; 482 - width: 100%; 483 - min-width: 0; 484 - } 452 + .ios-shortcut-banner-btn { 453 + display: inline-flex; 454 + align-items: center; 455 + gap: 6px; 456 + padding: 10px 20px; 457 + background: transparent; 458 + color: var(--text-secondary); 459 + font-size: 0.85rem; 460 + font-weight: 500; 461 + border: 1px solid var(--border); 462 + border-radius: 100px; 463 + text-decoration: none; 464 + transition: all 0.15s; 465 + } 485 466 486 - .main-content-wrapper { 487 - padding: 20px 16px; 488 - max-width: 100%; 489 - width: 100%; 490 - overflow-x: hidden; 491 - min-width: 0; 492 - } 467 + .ios-shortcut-banner-btn:hover { 468 + background: var(--bg-hover); 469 + color: var(--text-primary); 470 + } 493 471 494 - .mobile-nav { 472 + @media (max-width: 768px) { 473 + .ios-shortcut-banner { 495 474 display: block; 496 - max-width: 100vw; 497 475 } 476 + } 498 477 499 - .card, 500 - .annotation-card, 501 - .collection-card, 502 - .profile-header, 503 - .api-keys-section { 504 - overflow-x: hidden; 505 - max-width: 100%; 478 + @media (max-width: 768px) { 479 + .top-nav { 480 + display: none; 506 481 } 507 482 508 - code { 509 - word-break: break-all; 510 - overflow-wrap: break-word; 511 - } 512 - 513 - pre { 514 - overflow-x: auto; 515 - max-width: 100%; 483 + .mobile-bottom-nav { 484 + display: flex; 516 485 } 517 486 518 - input, 519 - textarea { 520 - max-width: 100%; 487 + .main-content { 488 + padding: 16px 12px 100px; 521 489 } 522 490 523 - .flex-row, 524 - [style*="display: flex"][style*="gap"] { 525 - flex-wrap: wrap; 491 + .feed-container { 492 + border-radius: var(--radius-md); 493 + padding: 4px; 526 494 } 495 + } 527 496 528 - .static-page { 529 - overflow-x: hidden; 497 + @media (max-width: 480px) { 498 + .main-content { 499 + padding: 16px 12px 100px; 530 500 } 531 501 532 - .static-page ol, 533 - .static-page ul { 534 - padding-left: 1.25rem; 502 + .page-title { 503 + font-size: 1.25rem; 535 504 } 536 505 537 - .static-page code { 538 - font-size: 0.75rem; 539 - word-break: break-all; 506 + .page-description { 507 + font-size: 0.85rem; 540 508 } 541 509 }
+242 -172
web/src/css/modals.css
··· 1 1 .modal-overlay { 2 2 position: fixed; 3 3 inset: 0; 4 - background: rgba(0, 0, 0, 0.5); 4 + background: rgba(0, 0, 0, 0.6); 5 5 display: flex; 6 6 align-items: center; 7 7 justify-content: center; 8 - padding: 16px; 9 - z-index: 50; 10 - animation: fadeIn 0.2s ease-out; 8 + padding: var(--spacing-md); 9 + z-index: 100; 10 + animation: fadeIn 0.15s ease-out; 11 + } 12 + 13 + .spinner { 14 + animation: spin 1s linear infinite; 15 + } 16 + 17 + @keyframes spin { 18 + from { 19 + transform: rotate(0deg); 20 + } 21 + 22 + to { 23 + transform: rotate(360deg); 24 + } 11 25 } 12 26 13 27 .modal-container { 14 28 background: var(--bg-secondary); 15 29 border-radius: var(--radius-lg); 16 30 width: 100%; 17 - max-width: 28rem; 31 + max-width: 420px; 18 32 border: 1px solid var(--border); 19 33 box-shadow: var(--shadow-lg); 20 - animation: zoomIn 0.2s ease-out; 34 + animation: modalIn 0.2s ease-out; 21 35 } 22 36 23 37 .modal-header { 24 38 display: flex; 25 39 align-items: center; 26 40 justify-content: space-between; 27 - padding: 16px; 41 + padding: var(--spacing-md); 28 42 border-bottom: 1px solid var(--border); 29 43 } 30 44 31 45 .modal-title { 32 - font-size: 1.25rem; 33 - font-weight: 700; 46 + font-size: 1rem; 47 + font-weight: 600; 34 48 color: var(--text-primary); 35 49 } 36 50 37 51 .modal-close-btn { 38 - padding: 8px; 52 + padding: 6px; 39 53 color: var(--text-tertiary); 40 - border-radius: var(--radius-md); 41 - transition: color 0.15s; 54 + border-radius: var(--radius-sm); 55 + transition: all 0.15s; 56 + background: none; 57 + border: none; 58 + cursor: pointer; 42 59 } 43 60 44 61 .modal-close-btn:hover { 45 62 color: var(--text-primary); 46 - background: var(--bg-hover); 63 + background: var(--bg-tertiary); 47 64 } 48 65 49 66 .modal-form { 50 - padding: 16px; 67 + padding: var(--spacing-md); 51 68 display: flex; 52 69 flex-direction: column; 53 - gap: 16px; 54 - } 55 - 56 - .icon-picker-tabs { 57 - display: flex; 58 - gap: 4px; 59 - margin-bottom: 12px; 60 - } 61 - 62 - .icon-picker-tab { 63 - flex: 1; 64 - padding: 8px 12px; 65 - background: var(--bg-primary); 66 - border: 1px solid var(--border); 67 - border-radius: var(--radius-md); 68 - color: var(--text-secondary); 69 - font-size: 0.85rem; 70 - font-weight: 500; 71 - cursor: pointer; 72 - transition: all 0.15s ease; 70 + gap: var(--spacing-md); 73 71 } 74 72 75 - .icon-picker-tab:hover { 76 - background: var(--bg-tertiary); 77 - } 78 - 79 - .icon-picker-tab.active { 80 - background: var(--accent); 81 - border-color: var(--accent); 82 - color: white; 83 - } 84 - 85 - .emoji-picker-wrapper { 73 + .modal-body { 74 + padding: var(--spacing-md); 86 75 display: flex; 87 76 flex-direction: column; 88 - gap: 10px; 89 - } 90 - 91 - .emoji-custom-input input { 92 - width: 100%; 93 - } 94 - 95 - .emoji-picker, 96 - .icon-picker { 97 - display: flex; 98 - flex-wrap: wrap; 99 - gap: 4px; 100 - max-height: 120px; 101 - overflow-y: auto; 102 - padding: 8px; 103 - background: var(--bg-primary); 104 - border: 1px solid var(--border); 105 - border-radius: var(--radius-md); 106 - } 107 - 108 - .emoji-option, 109 - .icon-option { 110 - width: 36px; 111 - height: 36px; 112 - display: flex; 113 - align-items: center; 114 - justify-content: center; 115 - font-size: 1.2rem; 116 - background: transparent; 117 - border: 2px solid transparent; 118 - border-radius: var(--radius-sm); 119 - cursor: pointer; 120 - transition: all 0.15s ease; 121 - color: var(--text-secondary); 122 - } 123 - 124 - .emoji-option:hover, 125 - .icon-option:hover { 126 - background: var(--bg-tertiary); 127 - transform: scale(1.1); 128 - color: var(--text-primary); 129 - } 130 - 131 - .emoji-option.selected, 132 - .icon-option.selected { 133 - border-color: var(--accent); 134 - background: var(--accent-subtle); 135 - color: var(--accent); 77 + gap: var(--spacing-md); 136 78 } 137 79 138 80 .modal-actions { 139 81 display: flex; 140 82 justify-content: flex-end; 141 - gap: 12px; 142 - padding-top: 8px; 83 + gap: var(--spacing-sm); 84 + padding-top: var(--spacing-sm); 143 85 } 144 86 145 87 @keyframes fadeIn { ··· 152 94 } 153 95 } 154 96 155 - @keyframes zoomIn { 97 + @keyframes modalIn { 156 98 from { 157 99 opacity: 0; 158 - transform: scale(0.95); 100 + transform: scale(0.96) translateY(-8px); 159 101 } 160 102 161 103 to { 162 104 opacity: 1; 163 - transform: scale(1); 105 + transform: scale(1) translateY(0); 164 106 } 165 107 } 166 108 ··· 170 112 171 113 .form-label { 172 114 display: block; 173 - font-size: 0.85rem; 174 - font-weight: 600; 115 + font-size: 0.8rem; 116 + font-weight: 500; 175 117 color: var(--text-secondary); 176 118 margin-bottom: 6px; 177 119 } ··· 180 122 .form-textarea, 181 123 .form-select { 182 124 width: 100%; 183 - padding: 8px 12px; 125 + padding: 10px 12px; 184 126 background: var(--bg-primary); 185 127 border: 1px solid var(--border); 186 128 border-radius: var(--radius-md); 187 129 color: var(--text-primary); 130 + font-size: 0.875rem; 188 131 transition: all 0.15s; 189 132 } 190 133 ··· 198 141 199 142 .form-textarea { 200 143 resize: none; 144 + min-height: 80px; 201 145 } 202 146 203 147 .input { 204 148 width: 100%; 205 - padding: 12px 14px; 206 - font-size: 0.95rem; 149 + padding: 10px 12px; 150 + font-size: 0.875rem; 207 151 color: var(--text-primary); 208 - background: var(--bg-secondary); 152 + background: var(--bg-primary); 209 153 border: 1px solid var(--border); 210 154 border-radius: var(--radius-md); 211 155 outline: none; ··· 214 158 215 159 .input:focus { 216 160 border-color: var(--accent); 217 - box-shadow: 0 0 0 3px var(--accent-subtle); 161 + box-shadow: 0 0 0 2px var(--accent-subtle); 218 162 } 219 163 220 164 .input::placeholder { 221 165 color: var(--text-tertiary); 222 166 } 223 167 168 + .icon-picker-tabs { 169 + display: flex; 170 + gap: 4px; 171 + margin-bottom: var(--spacing-sm); 172 + } 173 + 174 + .icon-picker-tab { 175 + flex: 1; 176 + padding: 8px 12px; 177 + background: var(--bg-tertiary); 178 + border: none; 179 + border-radius: var(--radius-sm); 180 + color: var(--text-secondary); 181 + font-size: 0.8rem; 182 + font-weight: 500; 183 + cursor: pointer; 184 + transition: all 0.15s ease; 185 + } 186 + 187 + .icon-picker-tab:hover { 188 + background: var(--bg-hover); 189 + } 190 + 191 + .icon-picker-tab.active { 192 + background: var(--accent); 193 + color: white; 194 + } 195 + 196 + .emoji-picker-wrapper { 197 + display: flex; 198 + flex-direction: column; 199 + gap: var(--spacing-sm); 200 + } 201 + 202 + .emoji-picker, 203 + .icon-picker { 204 + display: flex; 205 + flex-wrap: wrap; 206 + gap: 4px; 207 + max-height: 120px; 208 + overflow-y: auto; 209 + padding: var(--spacing-sm); 210 + background: var(--bg-primary); 211 + border: 1px solid var(--border); 212 + border-radius: var(--radius-md); 213 + } 214 + 215 + .emoji-option, 216 + .icon-option { 217 + width: 32px; 218 + height: 32px; 219 + display: flex; 220 + align-items: center; 221 + justify-content: center; 222 + font-size: 1rem; 223 + background: transparent; 224 + border: 2px solid transparent; 225 + border-radius: var(--radius-sm); 226 + cursor: pointer; 227 + transition: all 0.15s ease; 228 + color: var(--text-secondary); 229 + } 230 + 231 + .emoji-option:hover, 232 + .icon-option:hover { 233 + background: var(--bg-tertiary); 234 + color: var(--text-primary); 235 + } 236 + 237 + .emoji-option.selected, 238 + .icon-option.selected { 239 + border-color: var(--accent); 240 + background: var(--accent-subtle); 241 + color: var(--accent); 242 + } 243 + 224 244 .color-input-container { 225 245 display: flex; 226 246 align-items: center; 227 - gap: 12px; 247 + gap: var(--spacing-sm); 228 248 background: var(--bg-tertiary); 229 249 padding: 8px 12px; 230 250 border-radius: var(--radius-md); ··· 234 254 235 255 .color-input-wrapper { 236 256 position: relative; 237 - width: 32px; 238 - height: 32px; 257 + width: 28px; 258 + height: 28px; 239 259 border-radius: var(--radius-full); 240 260 overflow: hidden; 241 261 border: 2px solid var(--border); ··· 262 282 } 263 283 264 284 .signup-modal { 265 - background: var(--bg-card); 285 + background: var(--bg-secondary); 266 286 width: 100%; 267 - max-width: 480px; 268 - border-radius: 16px; 269 - padding: 24px; 287 + max-width: 440px; 288 + border-radius: var(--radius-lg); 289 + padding: var(--spacing-lg); 270 290 border: 1px solid var(--border); 271 291 position: relative; 272 292 max-height: 85vh; 273 293 overflow-y: auto; 274 - overscroll-behavior: contain; 275 - box-shadow: 0 10px 40px -10px rgba(0, 0, 0, 0.5); 294 + box-shadow: var(--shadow-lg); 276 295 } 277 296 278 297 .modal-close { 279 298 position: absolute; 280 - top: 16px; 281 - right: 16px; 299 + top: var(--spacing-md); 300 + right: var(--spacing-md); 282 301 background: none; 283 302 border: none; 284 303 color: var(--text-secondary); 285 304 cursor: pointer; 286 305 padding: 4px; 287 - border-radius: 50%; 306 + border-radius: var(--radius-sm); 288 307 } 289 308 290 309 .modal-close:hover { 291 - background: var(--bg-hover); 310 + background: var(--bg-tertiary); 292 311 color: var(--text-primary); 293 312 } 294 313 295 314 .signup-step h2 { 296 - font-size: 24px; 315 + font-size: 1.25rem; 297 316 margin-bottom: 8px; 298 - font-weight: 700; 317 + font-weight: 600; 299 318 } 300 319 301 320 .signup-subtitle { 302 321 color: var(--text-secondary); 303 - margin-bottom: 24px; 322 + font-size: 0.875rem; 323 + margin-bottom: var(--spacing-lg); 304 324 } 305 325 306 326 .provider-grid { 307 327 display: grid; 308 328 grid-template-columns: 1fr; 309 - gap: 12px; 329 + gap: var(--spacing-sm); 310 330 } 311 331 312 332 .provider-card { 313 333 display: flex; 314 334 align-items: center; 315 - gap: 16px; 316 - padding: 16px; 335 + gap: var(--spacing-md); 336 + padding: var(--spacing-md); 317 337 border: 1px solid var(--border); 318 - border-radius: 12px; 319 - background: var(--bg-element); 338 + border-radius: var(--radius-md); 339 + background: var(--bg-primary); 320 340 cursor: pointer; 321 341 text-align: left; 322 - transition: all 0.2s ease; 342 + transition: all 0.15s ease; 323 343 } 324 344 325 345 .provider-card:hover { 326 346 border-color: var(--accent); 327 - background: var(--bg-hover); 328 - transform: translateY(-1px); 347 + background: var(--bg-tertiary); 329 348 } 330 349 331 350 .provider-icon { 332 - width: 48px; 333 - height: 48px; 334 - border-radius: 10px; 335 - background: var(--bg-card); 351 + width: 40px; 352 + height: 40px; 353 + border-radius: var(--radius-md); 354 + background: var(--bg-tertiary); 336 355 display: flex; 337 356 align-items: center; 338 357 justify-content: center; ··· 343 362 344 363 .provider-icon.wide { 345 364 width: auto; 346 - padding: 0 12px; 365 + padding: 0 10px; 347 366 border: none; 348 367 background: transparent; 349 368 } 350 369 351 370 .provider-icon.wide img { 352 - max-height: 40px !important; 353 - height: 40px !important; 371 + max-height: 36px !important; 372 + height: 36px !important; 354 373 width: auto !important; 355 374 } 356 375 357 376 .provider-initial { 358 - font-size: 20px; 359 - font-weight: 700; 377 + font-size: 1rem; 378 + font-weight: 600; 360 379 } 361 380 362 381 .provider-info { ··· 365 384 366 385 .provider-info h3 { 367 386 font-weight: 600; 368 - font-size: 16px; 387 + font-size: 0.9rem; 369 388 margin-bottom: 2px; 370 389 } 371 390 372 391 .provider-info span { 373 392 color: var(--text-secondary); 374 - font-size: 13px; 393 + font-size: 0.8rem; 375 394 } 376 395 377 396 .provider-arrow { 378 397 color: var(--text-tertiary); 379 398 } 380 399 400 + .signup-recommended { 401 + position: relative; 402 + margin-bottom: var(--spacing-md); 403 + } 404 + 405 + .signup-recommended-badge { 406 + position: absolute; 407 + top: -8px; 408 + left: 12px; 409 + background: var(--accent); 410 + color: white; 411 + font-size: 0.7rem; 412 + font-weight: 600; 413 + padding: 2px 8px; 414 + border-radius: var(--radius-sm); 415 + text-transform: uppercase; 416 + letter-spacing: 0.5px; 417 + z-index: 1; 418 + } 419 + 420 + .provider-card-featured { 421 + border-color: var(--accent); 422 + background: var(--accent-subtle); 423 + } 424 + 425 + .provider-card-featured:hover { 426 + border-color: var(--accent); 427 + background: var(--bg-tertiary); 428 + } 429 + 430 + .signup-toggle-others { 431 + display: flex; 432 + align-items: center; 433 + justify-content: center; 434 + gap: 6px; 435 + width: 100%; 436 + padding: 10px; 437 + background: transparent; 438 + border: none; 439 + color: var(--text-secondary); 440 + font-size: 0.85rem; 441 + cursor: pointer; 442 + transition: color 0.15s; 443 + } 444 + 445 + .signup-toggle-others:hover { 446 + color: var(--text-primary); 447 + } 448 + 449 + .toggle-chevron { 450 + transition: transform 0.2s ease; 451 + transform: rotate(90deg); 452 + } 453 + 454 + .toggle-chevron.open { 455 + transform: rotate(-90deg); 456 + } 457 + 381 458 .signup-form { 382 459 display: flex; 383 460 flex-direction: column; 384 - gap: 16px; 461 + gap: var(--spacing-md); 385 462 } 386 463 387 464 .handle-input-group { 388 465 display: flex; 389 466 align-items: center; 390 - gap: 8px; 467 + gap: var(--spacing-sm); 391 468 } 392 469 393 470 .handle-suffix { 394 471 color: var(--text-tertiary); 395 - font-size: 14px; 472 + font-size: 0.85rem; 396 473 white-space: nowrap; 397 474 } 398 475 399 476 .error-message { 400 - color: #ff4444; 401 - background: rgba(255, 68, 68, 0.1); 402 - padding: 12px; 403 - border-radius: 8px; 404 - font-size: 13px; 477 + color: var(--error); 478 + background: rgba(255, 69, 58, 0.1); 479 + padding: 10px 12px; 480 + border-radius: var(--radius-md); 481 + font-size: 0.8rem; 405 482 display: flex; 406 483 align-items: center; 407 - gap: 8px; 484 + gap: var(--spacing-sm); 408 485 } 409 486 410 487 .step-header { 411 488 display: flex; 412 489 align-items: center; 413 - gap: 12px; 414 - margin-bottom: 24px; 490 + gap: var(--spacing-sm); 491 + margin-bottom: var(--spacing-lg); 415 492 } 416 493 417 494 .step-header h2 { 418 495 margin: 0; 419 - font-size: 20px; 496 + font-size: 1.1rem; 420 497 } 421 498 422 499 .btn-back { ··· 424 501 border: none; 425 502 color: var(--text-secondary); 426 503 cursor: pointer; 427 - font-size: 14px; 504 + font-size: 0.85rem; 428 505 padding: 0; 429 506 } 430 507 ··· 433 510 } 434 511 435 512 .legal-text { 436 - font-size: 12px; 513 + font-size: 0.75rem; 437 514 color: var(--text-tertiary); 438 515 text-align: center; 439 - margin-top: 8px; 440 - } 441 - 442 - .modal-body { 443 - padding: 16px; 444 - display: flex; 445 - flex-direction: column; 446 - gap: 16px; 516 + margin-top: var(--spacing-sm); 447 517 } 448 518 449 519 .links-input-group { 450 520 display: flex; 451 - gap: 8px; 452 - margin-bottom: 8px; 521 + gap: var(--spacing-sm); 522 + margin-bottom: var(--spacing-sm); 453 523 } 454 524 455 525 .links-input-group input { ··· 462 532 margin: 0; 463 533 display: flex; 464 534 flex-direction: column; 465 - gap: 8px; 535 + gap: var(--spacing-sm); 466 536 } 467 537 468 538 .link-item { 469 539 display: flex; 470 540 align-items: center; 471 - justify-content: map; 472 - gap: 8px; 541 + justify-content: space-between; 542 + gap: var(--spacing-sm); 473 543 padding: 8px 12px; 474 544 background: var(--bg-tertiary); 475 545 border: 1px solid var(--border); 476 546 border-radius: var(--radius-md); 477 - font-size: 0.9rem; 547 + font-size: 0.85rem; 478 548 color: var(--text-primary); 479 549 word-break: break-all; 480 550 } ··· 489 559 color: var(--text-tertiary); 490 560 cursor: pointer; 491 561 padding: 4px; 492 - border-radius: 4px; 562 + border-radius: var(--radius-sm); 493 563 display: flex; 494 564 align-items: center; 495 565 justify-content: center; 496 - font-size: 1.1rem; 566 + font-size: 1rem; 497 567 line-height: 1; 498 568 } 499 569 500 570 .btn-icon-sm:hover { 501 571 background: var(--bg-hover); 502 - color: #ff4444; 572 + color: var(--error); 503 573 } 504 574 505 575 .char-count { 506 576 text-align: right; 507 - font-size: 0.75rem; 577 + font-size: 0.7rem; 508 578 color: var(--text-tertiary); 509 579 margin-top: 4px; 510 580 }
+29 -31
web/src/css/skeleton.css
··· 2 2 0% { 3 3 background-position: -200% 0; 4 4 } 5 - 6 5 100% { 7 6 background-position: 200% 0; 8 7 } ··· 12 11 background: linear-gradient( 13 12 90deg, 14 13 var(--bg-tertiary) 25%, 15 - var(--bg-secondary) 50%, 14 + var(--bg-hover) 50%, 16 15 var(--bg-tertiary) 75% 17 16 ); 18 17 background-size: 200% 100%; ··· 21 20 } 22 21 23 22 .skeleton-card { 24 - padding: 24px 0; 25 - border-bottom: 1px solid var(--border); 23 + padding: var(--spacing-md); 26 24 display: flex; 27 25 flex-direction: column; 28 - gap: 16px; 26 + gap: var(--spacing-sm); 29 27 } 30 28 31 29 .skeleton-header { 32 30 display: flex; 33 31 align-items: center; 34 - gap: 12px; 32 + gap: var(--spacing-sm); 35 33 } 36 34 37 35 .skeleton-avatar { 38 - width: 36px; 39 - height: 36px; 40 - border-radius: 50%; 36 + width: 32px; 37 + height: 32px; 38 + border-radius: var(--radius-full); 39 + flex-shrink: 0; 41 40 } 42 41 43 42 .skeleton-meta { 44 43 display: flex; 45 44 flex-direction: column; 46 - gap: 6px; 45 + gap: 4px; 47 46 } 48 47 49 48 .skeleton-name { 50 - width: 120px; 51 - height: 14px; 49 + width: 100px; 50 + height: 12px; 52 51 } 53 52 54 53 .skeleton-handle { 55 - width: 80px; 56 - height: 12px; 54 + width: 70px; 55 + height: 10px; 57 56 } 58 57 59 58 .skeleton-content { 60 59 display: flex; 61 60 flex-direction: column; 62 - gap: 12px; 63 - padding-left: 48px; 61 + gap: var(--spacing-sm); 62 + padding-left: 40px; 64 63 } 65 64 66 65 .skeleton-source { 67 - width: 180px; 68 - height: 24px; 69 - border-radius: var(--radius-full); 66 + width: 140px; 67 + height: 10px; 70 68 } 71 69 72 70 .skeleton-highlight { 73 71 width: 100%; 74 - height: 60px; 75 - border-left: 2px solid var(--border); 72 + height: 48px; 73 + border-radius: var(--radius-sm); 76 74 } 77 75 78 76 .skeleton-text-1 { 79 - width: 90%; 80 - height: 14px; 77 + width: 85%; 78 + height: 12px; 81 79 } 82 80 83 81 .skeleton-text-2 { 84 - width: 60%; 85 - height: 14px; 82 + width: 55%; 83 + height: 12px; 86 84 } 87 85 88 86 .skeleton-actions { 89 87 display: flex; 90 - gap: 24px; 91 - padding-left: 48px; 92 - margin-top: 4px; 88 + gap: var(--spacing-md); 89 + padding-left: 40px; 90 + margin-top: var(--spacing-xs); 93 91 } 94 92 95 93 .skeleton-action { 96 - width: 24px; 97 - height: 24px; 94 + width: 20px; 95 + height: 20px; 98 96 border-radius: var(--radius-sm); 99 97 } 100 98 101 - @media (max-width: 600px) { 99 + @media (max-width: 768px) { 102 100 .skeleton-content, 103 101 .skeleton-actions { 104 102 padding-left: 0;
+9 -7
web/src/css/utilities.css
··· 539 539 540 540 .share-menu-container { 541 541 position: relative; 542 + z-index: 10; 542 543 } 543 544 544 545 .share-menu { ··· 546 547 top: 100%; 547 548 right: 0; 548 549 margin-top: 8px; 549 - background: var(--bg-primary); 550 + background: var(--bg-elevated); 550 551 border: 1px solid var(--border); 551 552 border-radius: var(--radius-lg); 552 - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); 553 - min-width: 180px; 554 - padding: 8px 0; 555 - z-index: 100; 553 + box-shadow: var(--shadow-lg); 554 + min-width: 200px; 555 + padding: 8px; 556 + z-index: 1000; 556 557 animation: fadeInUp 0.15s ease; 557 558 } 558 559 ··· 589 590 padding: 10px 14px; 590 591 background: none; 591 592 border: none; 593 + border-radius: var(--radius-md); 592 594 width: 100%; 593 595 text-align: left; 594 - font-size: 0.9rem; 596 + font-size: 0.875rem; 595 597 color: var(--text-primary); 596 598 cursor: pointer; 597 599 transition: all 0.1s ease; 598 600 } 599 601 600 602 .share-menu-item:hover { 601 - background: var(--bg-tertiary); 603 + background: var(--bg-hover); 602 604 } 603 605 604 606 .share-menu-icon {
+2 -1
web/src/index.css
··· 1 1 @import "./css/layout.css"; 2 2 @import "./css/base.css"; 3 3 @import "./css/buttons.css"; 4 - @import "./css/buttons.css"; 4 + @import "./css/cards.css"; 5 5 @import "./css/feed.css"; 6 6 @import "./css/profile.css"; 7 7 @import "./css/login.css"; ··· 11 11 @import "./css/notifications.css"; 12 12 @import "./css/skeleton.css"; 13 13 @import "./css/utilities.css"; 14 + @import "./css/landing.css";
+48 -27
web/src/pages/Bookmarks.jsx
··· 10 10 } from "../api/client"; 11 11 import { BookmarkIcon } from "../components/Icons"; 12 12 import BookmarkCard from "../components/BookmarkCard"; 13 + import CollectionItemCard from "../components/CollectionItemCard"; 13 14 import AddToCollectionModal from "../components/AddToCollectionModal"; 14 15 15 16 export default function Bookmarks() { ··· 251 252 )} 252 253 253 254 {loadingBookmarks ? ( 254 - <div className="feed"> 255 - {[1, 2, 3].map((i) => ( 256 - <div key={i} className="card"> 257 - <div 258 - className="skeleton skeleton-text" 259 - style={{ width: "40%" }} 260 - ></div> 261 - <div className="skeleton skeleton-text"></div> 262 - <div 263 - className="skeleton skeleton-text" 264 - style={{ width: "60%" }} 265 - ></div> 266 - </div> 267 - ))} 255 + <div className="feed-container"> 256 + <div className="feed"> 257 + {[1, 2, 3].map((i) => ( 258 + <div key={i} className="card"> 259 + <div 260 + className="skeleton skeleton-text" 261 + style={{ width: "40%" }} 262 + ></div> 263 + <div className="skeleton skeleton-text"></div> 264 + <div 265 + className="skeleton skeleton-text" 266 + style={{ width: "60%" }} 267 + ></div> 268 + </div> 269 + ))} 270 + </div> 268 271 </div> 269 272 ) : error ? ( 270 273 <div className="empty-state"> ··· 284 287 </p> 285 288 </div> 286 289 ) : ( 287 - <div className="feed"> 288 - {bookmarks.map((bookmark) => ( 289 - <BookmarkCard 290 - key={bookmark.id} 291 - bookmark={bookmark} 292 - onDelete={handleDelete} 293 - onAddToCollection={() => 294 - setCollectionModalState({ 295 - isOpen: true, 296 - uri: bookmark.uri || bookmark.id, 297 - }) 290 + <div className="feed-container"> 291 + <div className="feed"> 292 + {bookmarks.map((bookmark) => { 293 + if (bookmark.type === "CollectionItem") { 294 + return ( 295 + <CollectionItemCard 296 + key={bookmark.id} 297 + item={bookmark} 298 + onAddToCollection={(uri) => 299 + setCollectionModalState({ 300 + isOpen: true, 301 + uri: uri, 302 + }) 303 + } 304 + /> 305 + ); 298 306 } 299 - /> 300 - ))} 307 + return ( 308 + <BookmarkCard 309 + key={bookmark.id} 310 + bookmark={bookmark} 311 + onDelete={handleDelete} 312 + onAddToCollection={() => 313 + setCollectionModalState({ 314 + isOpen: true, 315 + uri: bookmark.uri || bookmark.id, 316 + }) 317 + } 318 + /> 319 + ); 320 + })} 321 + </div> 301 322 </div> 302 323 )} 303 324 {collectionModalState.isOpen && (
+41 -39
web/src/pages/CollectionDetail.jsx
··· 256 256 </div> 257 257 </div> 258 258 259 - <div className="feed"> 260 - {items.length === 0 ? ( 261 - <div className="empty-state card" style={{ borderStyle: "dashed" }}> 262 - <div className="empty-state-icon"> 263 - <Plus size={32} /> 259 + <div className="feed-container"> 260 + <div className="feed"> 261 + {items.length === 0 ? ( 262 + <div className="empty-state card" style={{ borderStyle: "dashed" }}> 263 + <div className="empty-state-icon"> 264 + <Plus size={32} /> 265 + </div> 266 + <h3 className="empty-state-title">Collection is empty</h3> 267 + <p className="empty-state-text"> 268 + {isOwner 269 + ? 'Add items to this collection from your feed or bookmarks using the "Collect" button.' 270 + : "This collection has no items yet."} 271 + </p> 264 272 </div> 265 - <h3 className="empty-state-title">Collection is empty</h3> 266 - <p className="empty-state-text"> 267 - {isOwner 268 - ? 'Add items to this collection from your feed or bookmarks using the "Collect" button.' 269 - : "This collection has no items yet."} 270 - </p> 271 - </div> 272 - ) : ( 273 - items.map((item) => ( 274 - <div key={item.uri} className="collection-item-wrapper"> 275 - {isOwner && 276 - !collection.uri.includes("network.cosmik.collection") && ( 277 - <button 278 - onClick={() => handleDeleteItem(item.uri)} 279 - className="collection-item-remove" 280 - title="Remove from collection" 281 - > 282 - <Trash2 size={14} /> 283 - </button> 284 - )} 273 + ) : ( 274 + items.map((item) => ( 275 + <div key={item.uri} className="collection-item-wrapper"> 276 + {isOwner && 277 + !collection.uri.includes("network.cosmik.collection") && ( 278 + <button 279 + onClick={() => handleDeleteItem(item.uri)} 280 + className="collection-item-remove" 281 + title="Remove from collection" 282 + > 283 + <Trash2 size={14} /> 284 + </button> 285 + )} 285 286 286 - {item.annotation ? ( 287 - <AnnotationCard annotation={item.annotation} /> 288 - ) : item.highlight ? ( 289 - <HighlightCard highlight={item.highlight} /> 290 - ) : item.bookmark ? ( 291 - <BookmarkCard bookmark={item.bookmark} /> 292 - ) : ( 293 - <div className="card" style={{ padding: "16px" }}> 294 - <p className="text-secondary">Item could not be loaded</p> 295 - </div> 296 - )} 297 - </div> 298 - )) 299 - )} 287 + {item.annotation ? ( 288 + <AnnotationCard annotation={item.annotation} /> 289 + ) : item.highlight ? ( 290 + <HighlightCard highlight={item.highlight} /> 291 + ) : item.bookmark ? ( 292 + <BookmarkCard bookmark={item.bookmark} /> 293 + ) : ( 294 + <div className="card" style={{ padding: "16px" }}> 295 + <p className="text-secondary">Item could not be loaded</p> 296 + </div> 297 + )} 298 + </div> 299 + )) 300 + )} 301 + </div> 300 302 </div> 301 303 302 304 {isOwner && (
+6
web/src/pages/Collections.jsx
··· 38 38 setEditingCollection(null); 39 39 }; 40 40 41 + const handleDelete = () => { 42 + fetchCollections(); 43 + setEditingCollection(null); 44 + }; 45 + 41 46 if (loading) { 42 47 return ( 43 48 <div className="feed-page"> ··· 121 126 setEditingCollection(null); 122 127 }} 123 128 onSuccess={handleCreateSuccess} 129 + onDelete={handleDelete} 124 130 collectionToEdit={editingCollection} 125 131 /> 126 132 </div>
+262 -228
web/src/pages/Feed.jsx
··· 1 - import { useState, useEffect } from "react"; 1 + import { useState, useEffect, useMemo, useCallback } from "react"; 2 2 import { useSearchParams } from "react-router-dom"; 3 3 import AnnotationCard, { HighlightCard } from "../components/AnnotationCard"; 4 4 import BookmarkCard from "../components/BookmarkCard"; 5 5 import CollectionItemCard from "../components/CollectionItemCard"; 6 6 import AnnotationSkeleton from "../components/AnnotationSkeleton"; 7 + import IOSInstallBanner from "../components/IOSInstallBanner"; 7 8 import { getAnnotationFeed, deleteHighlight } from "../api/client"; 8 9 import { AlertIcon, InboxIcon } from "../components/Icons"; 9 10 import { useAuth } from "../context/AuthContext"; 11 + import { X, ArrowUp } from "lucide-react"; 10 12 11 13 import AddToCollectionModal from "../components/AddToCollectionModal"; 12 14 ··· 25 27 const [annotations, setAnnotations] = useState([]); 26 28 const [loading, setLoading] = useState(true); 27 29 const [error, setError] = useState(null); 30 + const [hasMore, setHasMore] = useState(true); 31 + const [loadingMore, setLoadingMore] = useState(false); 28 32 29 33 useEffect(() => { 30 34 localStorage.setItem("feedFilter", filter); ··· 39 43 uri: null, 40 44 }); 41 45 42 - const [showIosBanner, setShowIosBanner] = useState(false); 43 - 44 - useEffect(() => { 45 - const isIOS = 46 - /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream; 47 - const hasDismissed = localStorage.getItem("iosBannerDismissed"); 48 - 49 - if (isIOS && !hasDismissed) { 50 - setShowIosBanner(true); 51 - } 52 - }, []); 53 - 54 - const dismissIosBanner = () => { 55 - setShowIosBanner(false); 56 - localStorage.setItem("iosBannerDismissed", "true"); 57 - }; 58 - 59 46 const { user } = useAuth(); 60 47 61 - useEffect(() => { 62 - async function fetchFeed() { 48 + const fetchFeed = useCallback( 49 + async (isLoadMore = false) => { 63 50 try { 64 - setLoading(true); 51 + if (isLoadMore) { 52 + setLoadingMore(true); 53 + } else { 54 + setLoading(true); 55 + } 56 + 65 57 let creatorDid = ""; 66 58 67 59 if (feedType === "my-feed") { ··· 70 62 } else { 71 63 setAnnotations([]); 72 64 setLoading(false); 65 + setLoadingMore(false); 73 66 return; 74 67 } 75 68 } 76 69 70 + const motivationMap = { 71 + commenting: "commenting", 72 + highlighting: "highlighting", 73 + bookmarking: "bookmarking", 74 + }; 75 + const motivation = motivationMap[filter] || ""; 76 + const limit = 50; 77 + const offset = isLoadMore ? annotations.length : 0; 78 + 77 79 const data = await getAnnotationFeed( 78 - 50, 79 - 0, 80 + limit, 81 + offset, 80 82 tagFilter || "", 81 83 creatorDid, 82 84 feedType, 83 - filter !== "all" ? filter : "", 85 + motivation, 84 86 ); 85 - setAnnotations(data.items || []); 87 + 88 + const newItems = data.items || []; 89 + if (newItems.length < limit) { 90 + setHasMore(false); 91 + } else { 92 + setHasMore(true); 93 + } 94 + 95 + if (isLoadMore) { 96 + setAnnotations((prev) => [...prev, ...newItems]); 97 + } else { 98 + setAnnotations(newItems); 99 + } 86 100 } catch (err) { 87 101 setError(err.message); 88 102 } finally { 89 103 setLoading(false); 104 + setLoadingMore(false); 105 + } 106 + }, 107 + [tagFilter, feedType, filter, user, annotations.length], 108 + ); 109 + 110 + useEffect(() => { 111 + fetchFeed(false); 112 + }, [fetchFeed]); 113 + 114 + const deduplicatedAnnotations = useMemo(() => { 115 + const inCollectionUris = new Set(); 116 + for (const item of annotations) { 117 + if (item.type === "CollectionItem") { 118 + const inner = item.annotation || item.highlight || item.bookmark; 119 + if (inner) { 120 + if (inner.uri) inCollectionUris.add(inner.uri.trim()); 121 + if (inner.id) inCollectionUris.add(inner.id.trim()); 122 + } 90 123 } 91 124 } 92 - fetchFeed(); 93 - }, [tagFilter, filter, feedType, user]); 125 + 126 + const result = []; 127 + 128 + for (const item of annotations) { 129 + if (item.type !== "CollectionItem") { 130 + const itemUri = (item.uri || "").trim(); 131 + const itemId = (item.id || "").trim(); 132 + if ( 133 + (itemUri && inCollectionUris.has(itemUri)) || 134 + (itemId && inCollectionUris.has(itemId)) 135 + ) { 136 + continue; 137 + } 138 + } 139 + 140 + result.push(item); 141 + } 142 + 143 + return result; 144 + }, [annotations]); 94 145 95 146 const filteredAnnotations = 96 147 feedType === "all" || ··· 99 150 feedType === "margin" || 100 151 feedType === "my-feed" 101 152 ? filter === "all" 102 - ? annotations 103 - : annotations.filter((a) => { 153 + ? deduplicatedAnnotations 154 + : deduplicatedAnnotations.filter((a) => { 155 + if (a.type === "CollectionItem") { 156 + if (filter === "commenting") return !!a.annotation; 157 + if (filter === "highlighting") return !!a.highlight; 158 + if (filter === "bookmarking") return !!a.bookmark; 159 + } 104 160 if (filter === "commenting") 105 161 return a.motivation === "commenting" || a.type === "Annotation"; 106 162 if (filter === "highlighting") ··· 109 165 return a.motivation === "bookmarking" || a.type === "Bookmark"; 110 166 return a.motivation === filter; 111 167 }) 112 - : annotations; 168 + : deduplicatedAnnotations; 113 169 114 170 return ( 115 171 <div className="feed-page"> 116 172 <div className="page-header"> 117 173 <h1 className="page-title">Feed</h1> 118 174 <p className="page-description"> 119 - See what people are annotating, highlighting, and bookmarking 175 + See what people are annotating and bookmarking 120 176 </p> 121 - {tagFilter && ( 122 - <div 123 - style={{ 124 - marginTop: "16px", 125 - display: "flex", 126 - alignItems: "center", 127 - gap: "8px", 128 - }} 177 + </div> 178 + 179 + {tagFilter && ( 180 + <div className="active-filter-banner"> 181 + <span> 182 + Filtering by <strong>#{tagFilter}</strong> 183 + </span> 184 + <button 185 + onClick={() => 186 + setSearchParams((prev) => { 187 + const next = new URLSearchParams(prev); 188 + next.delete("tag"); 189 + return next; 190 + }) 191 + } 192 + className="active-filter-clear" 193 + aria-label="Clear filter" 129 194 > 130 - <span 131 - style={{ fontSize: "0.9rem", color: "var(--text-secondary)" }} 132 - > 133 - Filtering by tag: <strong>#{tagFilter}</strong> 134 - </span> 195 + <X size={14} /> 196 + </button> 197 + </div> 198 + )} 199 + 200 + <div className="feed-controls"> 201 + <div className="feed-filters"> 202 + {[ 203 + { key: "all", label: "All" }, 204 + { key: "popular", label: "Popular" }, 205 + { key: "margin", label: "Margin" }, 206 + { key: "semble", label: "Semble" }, 207 + ...(user ? [{ key: "my-feed", label: "Mine" }] : []), 208 + ].map(({ key, label }) => ( 135 209 <button 136 - onClick={() => 137 - setSearchParams((prev) => { 138 - const next = new URLSearchParams(prev); 139 - next.delete("tag"); 140 - return next; 141 - }) 142 - } 143 - className="btn btn-sm" 144 - style={{ padding: "2px 8px", fontSize: "0.8rem" }} 210 + key={key} 211 + className={`filter-tab ${feedType === key ? "active" : ""}`} 212 + onClick={() => setFeedType(key)} 145 213 > 146 - Clear 214 + {label} 147 215 </button> 148 - </div> 149 - )} 150 - </div> 216 + ))} 217 + </div> 151 218 152 - {showIosBanner && ( 153 - <div 154 - className="ios-banner" 155 - style={{ 156 - background: "var(--bg-secondary)", 157 - border: "1px solid var(--border)", 158 - borderRadius: "var(--radius-md)", 159 - padding: "12px", 160 - marginBottom: "20px", 161 - display: "flex", 162 - alignItems: "center", 163 - justifyContent: "space-between", 164 - gap: "12px", 165 - }} 166 - > 167 - <div style={{ flex: 1 }}> 168 - <h3 169 - style={{ 170 - fontSize: "0.9rem", 171 - fontWeight: 600, 172 - marginBottom: "4px", 173 - }} 174 - > 175 - Get the iOS Shortcut 176 - </h3> 177 - <p style={{ fontSize: "0.8rem", color: "var(--text-secondary)" }}> 178 - Easily save links from Safari using our new shortcut. 179 - </p> 180 - </div> 181 - <div style={{ display: "flex", gap: "8px", alignItems: "center" }}> 182 - <a 183 - href="https://www.icloud.com/shortcuts/21c87edf29b046db892c9e57dac6d1fd" 184 - target="_blank" 185 - rel="noopener noreferrer" 186 - className="btn btn-primary btn-sm" 187 - style={{ whiteSpace: "nowrap" }} 188 - > 189 - Get It 190 - </a> 219 + <div className="feed-filters"> 220 + {[ 221 + { key: "all", label: "All" }, 222 + { key: "commenting", label: "Notes" }, 223 + { key: "highlighting", label: "Highlights" }, 224 + { key: "bookmarking", label: "Bookmarks" }, 225 + ].map(({ key, label }) => ( 191 226 <button 192 - className="btn btn-sm" 193 - onClick={dismissIosBanner} 194 - style={{ 195 - color: "var(--text-tertiary)", 196 - padding: "4px", 197 - height: "auto", 198 - }} 227 + key={key} 228 + className={`filter-pill ${filter === key ? "active" : ""}`} 229 + onClick={() => setFilter(key)} 199 230 > 200 - 231 + {label} 201 232 </button> 202 - </div> 233 + ))} 203 234 </div> 204 - )} 205 - 206 - {} 207 - <div 208 - className="feed-filters" 209 - style={{ 210 - marginBottom: "12px", 211 - borderBottom: "1px solid var(--border)", 212 - }} 213 - > 214 - <button 215 - className={`filter-tab ${feedType === "all" ? "active" : ""}`} 216 - onClick={() => setFeedType("all")} 217 - > 218 - All 219 - </button> 220 - <button 221 - className={`filter-tab ${feedType === "popular" ? "active" : ""}`} 222 - onClick={() => setFeedType("popular")} 223 - > 224 - Popular 225 - </button> 226 - <button 227 - className={`filter-tab ${feedType === "margin" ? "active" : ""}`} 228 - onClick={() => setFeedType("margin")} 229 - > 230 - Margin 231 - </button> 232 - <button 233 - className={`filter-tab ${feedType === "semble" ? "active" : ""}`} 234 - onClick={() => setFeedType("semble")} 235 - > 236 - Semble 237 - </button> 238 - {user && ( 239 - <button 240 - className={`filter-tab ${feedType === "my-feed" ? "active" : ""}`} 241 - onClick={() => setFeedType("my-feed")} 242 - > 243 - My Feed 244 - </button> 245 - )} 246 235 </div> 247 236 248 - <div className="feed-filters"> 249 - <button 250 - className={`filter-pill ${filter === "all" ? "active" : ""}`} 251 - onClick={() => setFilter("all")} 252 - > 253 - All Types 254 - </button> 255 - <button 256 - className={`filter-pill ${filter === "commenting" ? "active" : ""}`} 257 - onClick={() => setFilter("commenting")} 258 - > 259 - Annotations 260 - </button> 261 - <button 262 - className={`filter-pill ${filter === "highlighting" ? "active" : ""}`} 263 - onClick={() => setFilter("highlighting")} 264 - > 265 - Highlights 266 - </button> 267 - <button 268 - className={`filter-pill ${filter === "bookmarking" ? "active" : ""}`} 269 - onClick={() => setFilter("bookmarking")} 270 - > 271 - Bookmarks 272 - </button> 273 - </div> 237 + <IOSInstallBanner /> 274 238 275 239 {loading ? ( 276 - <div className="feed"> 277 - {[1, 2, 3, 4, 5].map((i) => ( 278 - <AnnotationSkeleton key={i} /> 279 - ))} 240 + <div className="feed-container"> 241 + <div className="feed"> 242 + {[1, 2, 3, 4, 5].map((i) => ( 243 + <AnnotationSkeleton key={i} /> 244 + ))} 245 + </div> 280 246 </div> 281 247 ) : ( 282 248 <> 283 249 {error && ( 284 250 <div className="empty-state"> 285 251 <div className="empty-state-icon"> 286 - <AlertIcon size={32} /> 252 + <AlertIcon size={24} /> 287 253 </div> 288 254 <h3 className="empty-state-title">Something went wrong</h3> 289 255 <p className="empty-state-text">{error}</p> ··· 293 259 {!error && filteredAnnotations.length === 0 && ( 294 260 <div className="empty-state"> 295 261 <div className="empty-state-icon"> 296 - <InboxIcon size={32} /> 262 + <InboxIcon size={24} /> 297 263 </div> 298 264 <h3 className="empty-state-title">No items yet</h3> 299 265 <p className="empty-state-text"> ··· 305 271 )} 306 272 307 273 {!error && filteredAnnotations.length > 0 && ( 308 - <div className="feed"> 309 - {filteredAnnotations.map((item) => { 310 - if (item.type === "CollectionItem") { 311 - return <CollectionItemCard key={item.id} item={item} />; 312 - } 313 - if ( 314 - item.type === "Highlight" || 315 - item.motivation === "highlighting" 316 - ) { 274 + <div className="feed-container"> 275 + <div className="feed"> 276 + {filteredAnnotations.map((item) => { 277 + if (item.type === "CollectionItem") { 278 + return ( 279 + <CollectionItemCard 280 + key={item.id} 281 + item={item} 282 + onAddToCollection={(uri) => 283 + setCollectionModalState({ 284 + isOpen: true, 285 + uri: uri, 286 + }) 287 + } 288 + /> 289 + ); 290 + } 291 + if ( 292 + item.type === "Highlight" || 293 + item.motivation === "highlighting" 294 + ) { 295 + return ( 296 + <HighlightCard 297 + key={item.id} 298 + highlight={item} 299 + onDelete={async (uri) => { 300 + const rkey = uri.split("/").pop(); 301 + await deleteHighlight(rkey); 302 + setAnnotations((prev) => 303 + prev.filter((a) => a.id !== item.id), 304 + ); 305 + }} 306 + onAddToCollection={() => 307 + setCollectionModalState({ 308 + isOpen: true, 309 + uri: item.uri || item.id, 310 + }) 311 + } 312 + /> 313 + ); 314 + } 315 + if ( 316 + item.type === "Bookmark" || 317 + item.motivation === "bookmarking" 318 + ) { 319 + return ( 320 + <BookmarkCard 321 + key={item.id} 322 + bookmark={item} 323 + onAddToCollection={() => 324 + setCollectionModalState({ 325 + isOpen: true, 326 + uri: item.uri || item.id, 327 + }) 328 + } 329 + /> 330 + ); 331 + } 317 332 return ( 318 - <HighlightCard 333 + <AnnotationCard 319 334 key={item.id} 320 - highlight={item} 321 - onDelete={async (uri) => { 322 - const rkey = uri.split("/").pop(); 323 - await deleteHighlight(rkey); 324 - setAnnotations((prev) => 325 - prev.filter((a) => a.id !== item.id), 326 - ); 327 - }} 335 + annotation={item} 328 336 onAddToCollection={() => 329 337 setCollectionModalState({ 330 338 isOpen: true, ··· 333 341 } 334 342 /> 335 343 ); 336 - } 337 - if ( 338 - item.type === "Bookmark" || 339 - item.motivation === "bookmarking" 340 - ) { 341 - return ( 342 - <BookmarkCard 343 - key={item.id} 344 - bookmark={item} 345 - onAddToCollection={() => 346 - setCollectionModalState({ 347 - isOpen: true, 348 - uri: item.uri || item.id, 349 - }) 350 - } 351 - /> 352 - ); 353 - } 354 - return ( 355 - <AnnotationCard 356 - key={item.id} 357 - annotation={item} 358 - onAddToCollection={() => 359 - setCollectionModalState({ 360 - isOpen: true, 361 - uri: item.uri || item.id, 362 - }) 363 - } 364 - /> 365 - ); 366 - })} 344 + })} 345 + </div> 346 + 347 + {hasMore && ( 348 + <div 349 + style={{ 350 + display: "flex", 351 + justifyContent: "center", 352 + marginTop: "12px", 353 + paddingBottom: "24px", 354 + }} 355 + > 356 + <button 357 + onClick={() => fetchFeed(true)} 358 + disabled={loadingMore} 359 + className="feed-load-more" 360 + > 361 + {loadingMore ? "Loading..." : "View More"} 362 + </button> 363 + </div> 364 + )} 367 365 </div> 368 366 )} 369 367 </> ··· 376 374 annotationUri={collectionModalState.uri} 377 375 /> 378 376 )} 377 + 378 + <BackToTopButton /> 379 379 </div> 380 380 ); 381 381 } 382 + 383 + function BackToTopButton() { 384 + const [isVisible, setIsVisible] = useState(false); 385 + 386 + useEffect(() => { 387 + const toggleVisibility = () => { 388 + if (window.scrollY > 300) { 389 + setIsVisible(true); 390 + } else { 391 + setIsVisible(false); 392 + } 393 + }; 394 + 395 + window.addEventListener("scroll", toggleVisibility); 396 + return () => window.removeEventListener("scroll", toggleVisibility); 397 + }, []); 398 + 399 + const scrollToTop = () => { 400 + window.scrollTo({ 401 + top: 0, 402 + behavior: "smooth", 403 + }); 404 + }; 405 + 406 + return ( 407 + <button 408 + className={`back-to-top-btn ${isVisible ? "visible" : ""}`} 409 + onClick={scrollToTop} 410 + aria-label="Back to top" 411 + > 412 + <ArrowUp size={20} /> 413 + </button> 414 + ); 415 + }
+26 -22
web/src/pages/Highlights.jsx
··· 82 82 </div> 83 83 84 84 {loadingHighlights ? ( 85 - <div className="feed"> 86 - {[1, 2, 3].map((i) => ( 87 - <div key={i} className="card"> 88 - <div 89 - className="skeleton skeleton-text" 90 - style={{ width: "40%" }} 91 - ></div> 92 - <div className="skeleton skeleton-text"></div> 93 - <div 94 - className="skeleton skeleton-text" 95 - style={{ width: "60%" }} 96 - ></div> 97 - </div> 98 - ))} 85 + <div className="feed-container"> 86 + <div className="feed"> 87 + {[1, 2, 3].map((i) => ( 88 + <div key={i} className="card"> 89 + <div 90 + className="skeleton skeleton-text" 91 + style={{ width: "40%" }} 92 + ></div> 93 + <div className="skeleton skeleton-text"></div> 94 + <div 95 + className="skeleton skeleton-text" 96 + style={{ width: "60%" }} 97 + ></div> 98 + </div> 99 + ))} 100 + </div> 99 101 </div> 100 102 ) : error ? ( 101 103 <div className="empty-state"> ··· 114 116 </p> 115 117 </div> 116 118 ) : ( 117 - <div className="feed"> 118 - {highlights.map((highlight) => ( 119 - <HighlightCard 120 - key={highlight.id} 121 - highlight={highlight} 122 - onDelete={handleDelete} 123 - /> 124 - ))} 119 + <div className="feed-container"> 120 + <div className="feed"> 121 + {highlights.map((highlight) => ( 122 + <HighlightCard 123 + key={highlight.id} 124 + highlight={highlight} 125 + onDelete={handleDelete} 126 + /> 127 + ))} 128 + </div> 125 129 </div> 126 130 )} 127 131 </div>
+738
web/src/pages/Landing.jsx
··· 1 + import { useState, useEffect, useRef } from "react"; 2 + import { Link } from "react-router-dom"; 3 + import { useAuth } from "../context/AuthContext"; 4 + import { 5 + MessageSquare, 6 + Highlighter, 7 + Users, 8 + ArrowRight, 9 + Github, 10 + Database, 11 + Shield, 12 + Zap, 13 + } from "lucide-react"; 14 + import { SiFirefox, SiGooglechrome, SiBluesky } from "react-icons/si"; 15 + import { FaEdge } from "react-icons/fa"; 16 + import logo from "../assets/logo.svg"; 17 + 18 + const isFirefox = 19 + typeof navigator !== "undefined" && /Firefox/i.test(navigator.userAgent); 20 + const isEdge = 21 + typeof navigator !== "undefined" && /Edg/i.test(navigator.userAgent); 22 + 23 + function getExtensionInfo() { 24 + if (isFirefox) { 25 + return { 26 + url: "https://addons.mozilla.org/en-US/firefox/addon/margin/", 27 + Icon: SiFirefox, 28 + label: "Firefox", 29 + }; 30 + } 31 + if (isEdge) { 32 + return { 33 + url: "https://microsoftedge.microsoft.com/addons/detail/margin/nfjnmllpdgcdnhmmggjihjbidmeadddn", 34 + Icon: FaEdge, 35 + label: "Edge", 36 + }; 37 + } 38 + return { 39 + url: "https://chromewebstore.google.com/detail/margin/cgpmbiiagnehkikhcbnhiagfomajncpa/", 40 + Icon: SiGooglechrome, 41 + label: "Chrome", 42 + }; 43 + } 44 + 45 + import { getAnnotations, normalizeAnnotation } from "../api/client"; 46 + import { formatDistanceToNow } from "date-fns"; 47 + 48 + function DemoAnnotation() { 49 + const [annotations, setAnnotations] = useState([]); 50 + const [loading, setLoading] = useState(true); 51 + const [hoverPos, setHoverPos] = useState(null); 52 + const [hoverVisible, setHoverVisible] = useState(false); 53 + const [hoverAuthors, setHoverAuthors] = useState([]); 54 + 55 + const [showPopover, setShowPopover] = useState(false); 56 + const [popoverPos, setPopoverPos] = useState(null); 57 + const [popoverAnnotations, setPopoverAnnotations] = useState([]); 58 + 59 + const highlightRef = useRef(null); 60 + const articleRef = useRef(null); 61 + 62 + useEffect(() => { 63 + getAnnotations({ source: "https://en.wikipedia.org/wiki/AT_Protocol" }) 64 + .then((res) => { 65 + const rawItems = res.items || (Array.isArray(res) ? res : []); 66 + const normalized = rawItems.map(normalizeAnnotation); 67 + setAnnotations(normalized); 68 + }) 69 + .catch((err) => { 70 + console.error("Failed to fetch demo annotations:", err); 71 + }) 72 + .finally(() => { 73 + setLoading(false); 74 + }); 75 + }, []); 76 + 77 + useEffect(() => { 78 + if (!showPopover) return; 79 + const handleClickOutside = () => setShowPopover(false); 80 + document.addEventListener("click", handleClickOutside); 81 + return () => document.removeEventListener("click", handleClickOutside); 82 + }, [showPopover]); 83 + 84 + const getMatches = () => { 85 + return annotations.filter( 86 + (a) => 87 + (a.selector?.exact && 88 + a.selector.exact.includes("A handle serves as")) || 89 + (a.quote && a.quote.includes("A handle serves as")), 90 + ); 91 + }; 92 + 93 + const handleMouseEnter = () => { 94 + const matches = getMatches(); 95 + const authorsMap = new Map(); 96 + matches.forEach((a) => { 97 + const author = a.author || a.creator || { handle: "unknown" }; 98 + const id = author.did || author.handle; 99 + if (!authorsMap.has(id)) authorsMap.set(id, author); 100 + }); 101 + const unique = Array.from(authorsMap.values()); 102 + 103 + setHoverAuthors(unique); 104 + 105 + if (highlightRef.current && articleRef.current) { 106 + const spanRect = highlightRef.current.getBoundingClientRect(); 107 + const articleRect = articleRef.current.getBoundingClientRect(); 108 + 109 + const visibleCount = Math.min(unique.length, 3); 110 + const hasOverflow = unique.length > 3; 111 + const countForCalc = visibleCount + (hasOverflow ? 1 : 0); 112 + const width = countForCalc > 0 ? countForCalc * 18 + 10 : 0; 113 + 114 + const top = spanRect.top - articleRect.top + spanRect.height / 2 - 14; 115 + const left = spanRect.left - articleRect.left - width; 116 + 117 + setHoverPos({ top, left }); 118 + setHoverVisible(true); 119 + } 120 + }; 121 + 122 + const handleMouseLeave = () => { 123 + setHoverVisible(false); 124 + }; 125 + 126 + const handleHighlightClick = (e) => { 127 + e.stopPropagation(); 128 + const matches = getMatches(); 129 + setPopoverAnnotations(matches); 130 + 131 + if (highlightRef.current && articleRef.current) { 132 + const spanRect = highlightRef.current.getBoundingClientRect(); 133 + const articleRect = articleRef.current.getBoundingClientRect(); 134 + 135 + const top = spanRect.top - articleRect.top + spanRect.height + 10; 136 + let left = spanRect.left - articleRect.left; 137 + 138 + if (left + 300 > articleRect.width) { 139 + left = articleRect.width - 300; 140 + } 141 + 142 + setPopoverPos({ top, left }); 143 + setShowPopover(true); 144 + } 145 + }; 146 + 147 + const maxShow = 3; 148 + const displayHoverAuthors = hoverAuthors.slice(0, maxShow); 149 + const hoverOverflow = hoverAuthors.length - maxShow; 150 + 151 + return ( 152 + <div className="demo-window"> 153 + <div className="demo-browser-bar"> 154 + <div className="demo-browser-dots"> 155 + <span></span> 156 + <span></span> 157 + <span></span> 158 + </div> 159 + <div className="demo-browser-url"> 160 + <span>en.wikipedia.org/wiki/AT_Protocol</span> 161 + </div> 162 + </div> 163 + <div className="demo-content"> 164 + <div 165 + className="demo-article" 166 + ref={articleRef} 167 + style={{ position: "relative" }} 168 + > 169 + {hoverPos && hoverAuthors.length > 0 && ( 170 + <div 171 + className={`demo-hover-indicator ${hoverVisible ? "visible" : ""}`} 172 + style={{ 173 + top: hoverPos.top, 174 + left: hoverPos.left, 175 + cursor: "pointer", 176 + }} 177 + onClick={handleHighlightClick} 178 + > 179 + {displayHoverAuthors.map((author, i) => 180 + author.avatar ? ( 181 + <img 182 + key={i} 183 + src={author.avatar} 184 + className="demo-hover-avatar" 185 + alt={author.handle} 186 + onError={(e) => { 187 + e.target.style.display = "none"; 188 + e.target.nextSibling.style.display = "flex"; 189 + }} 190 + /> 191 + ) : ( 192 + <div key={i} className="demo-hover-avatar-fallback"> 193 + {author.handle?.[0]?.toUpperCase() || "U"} 194 + </div> 195 + ), 196 + )} 197 + {hoverOverflow > 0 && ( 198 + <div 199 + className="demo-hover-avatar-fallback" 200 + style={{ 201 + background: "var(--bg-elevated)", 202 + color: "var(--text-secondary)", 203 + fontSize: 10, 204 + }} 205 + > 206 + +{hoverOverflow} 207 + </div> 208 + )} 209 + </div> 210 + )} 211 + 212 + {showPopover && popoverPos && ( 213 + <div 214 + className="demo-popover" 215 + style={{ 216 + top: popoverPos.top, 217 + left: popoverPos.left, 218 + }} 219 + onClick={(e) => e.stopPropagation()} 220 + > 221 + <div className="demo-popover-header"> 222 + <span> 223 + {popoverAnnotations.length}{" "} 224 + {popoverAnnotations.length === 1 ? "Comment" : "Comments"} 225 + </span> 226 + <button 227 + className="demo-popover-close" 228 + onClick={() => setShowPopover(false)} 229 + > 230 + 231 + </button> 232 + </div> 233 + <div className="demo-popover-scroll-area"> 234 + {popoverAnnotations.length === 0 ? ( 235 + <div style={{ padding: 14, fontSize: 13, color: "#666" }}> 236 + No comments 237 + </div> 238 + ) : ( 239 + popoverAnnotations.map((ann, i) => ( 240 + <div key={ann.uri || i} className="demo-comment-item"> 241 + <div className="demo-comment-header"> 242 + <img 243 + src={ann.author?.avatar || logo} 244 + className="demo-comment-avatar" 245 + onError={(e) => (e.target.src = logo)} 246 + alt="" 247 + /> 248 + <span className="demo-comment-handle"> 249 + @{ann.author?.handle || "user"} 250 + </span> 251 + </div> 252 + <div className="demo-comment-text"> 253 + {ann.text || ann.body?.value} 254 + </div> 255 + <div className="demo-comment-actions"> 256 + <button className="demo-comment-action-btn"> 257 + Reply 258 + </button> 259 + <button className="demo-comment-action-btn"> 260 + Share 261 + </button> 262 + </div> 263 + </div> 264 + )) 265 + )} 266 + </div> 267 + </div> 268 + )} 269 + <p className="demo-text"> 270 + The AT Protocol utilizes a dual identifier system: a mutable handle, 271 + in the form of a domain name, and an immutable decentralized 272 + identifier (DID). 273 + </p> 274 + <p className="demo-text"> 275 + <span 276 + className="demo-highlight" 277 + ref={highlightRef} 278 + onMouseEnter={handleMouseEnter} 279 + onMouseLeave={handleMouseLeave} 280 + onClick={handleHighlightClick} 281 + style={{ cursor: "pointer" }} 282 + > 283 + A handle serves as a verifiable user identifier. 284 + </span>{" "} 285 + Verification is by either of two equivalent methods proving control 286 + of the domain name: Either a DNS query of a resource record with the 287 + same name as the handle, or a request for a text file from a Web 288 + service with the same name. 289 + </p> 290 + <p className="demo-text"> 291 + DIDs resolve to DID documents, which contain references to key user 292 + metadata, such as the user&apos;s handle, public keys, and data 293 + repository. While any DID method could, in theory, be used by the 294 + protocol if its components provide support for the method, in 295 + practice only two methods are supported (&apos;blessed&apos;) by the 296 + protocol&apos;s reference implementations: did:plc and did:web. The 297 + validity of these identifiers can be verified by a registry which 298 + hosts the DID&apos;s associated document and a file that is hosted 299 + at a well-known location on the connected domain name, respectively. 300 + </p> 301 + </div> 302 + <div className="demo-sidebar"> 303 + <div className="demo-sidebar-header"> 304 + <div className="demo-logo-section"> 305 + <span className="demo-logo-icon"> 306 + <img src={logo} alt="" style={{ width: 16, height: 16 }} /> 307 + </span> 308 + <span className="demo-logo-text">Margin</span> 309 + </div> 310 + <div className="demo-user-section"> 311 + <span className="demo-user-handle">@margin.at</span> 312 + </div> 313 + </div> 314 + <div className="demo-page-info"> 315 + <span>en.wikipedia.org</span> 316 + </div> 317 + <div className="demo-annotations-list"> 318 + {loading ? ( 319 + <div style={{ padding: 20, textAlign: "center", color: "#666" }}> 320 + Loading... 321 + </div> 322 + ) : annotations.length > 0 ? ( 323 + annotations.map((ann, i) => ( 324 + <div 325 + key={ann.uri || i} 326 + className={`demo-annotation ${i > 0 ? "demo-annotation-secondary" : ""}`} 327 + > 328 + <div className="demo-annotation-header"> 329 + <div 330 + className="demo-avatar" 331 + style={{ background: "transparent" }} 332 + > 333 + <img 334 + src={ann.author?.avatar || logo} 335 + alt={ann.author?.handle || "User"} 336 + style={{ 337 + width: "100%", 338 + height: "100%", 339 + borderRadius: "50%", 340 + }} 341 + onError={(e) => { 342 + e.target.src = logo; 343 + }} 344 + /> 345 + </div> 346 + <div className="demo-meta"> 347 + <span className="demo-author"> 348 + @{ann.author?.handle || "margin.at"} 349 + </span> 350 + <span className="demo-time"> 351 + {ann.createdAt 352 + ? formatDistanceToNow(new Date(ann.createdAt), { 353 + addSuffix: true, 354 + }) 355 + : "recently"} 356 + </span> 357 + </div> 358 + </div> 359 + {ann.selector?.exact && ( 360 + <p className="demo-quote"> 361 + &ldquo;{ann.selector.exact}&rdquo; 362 + </p> 363 + )} 364 + <p className="demo-comment">{ann.text || ann.body?.value}</p> 365 + <button className="demo-jump-btn">Jump to text →</button> 366 + </div> 367 + )) 368 + ) : ( 369 + <div 370 + style={{ 371 + padding: 20, 372 + textAlign: "center", 373 + color: "var(--text-tertiary)", 374 + }} 375 + > 376 + No annotations found. 377 + </div> 378 + )} 379 + </div> 380 + </div> 381 + </div> 382 + </div> 383 + ); 384 + } 385 + 386 + export default function Landing() { 387 + const { user } = useAuth(); 388 + const ext = getExtensionInfo(); 389 + 390 + return ( 391 + <div className="landing-page"> 392 + <nav className="landing-nav"> 393 + <Link to="/" className="landing-logo"> 394 + <img src={logo} alt="Margin" /> 395 + <span>Margin</span> 396 + </Link> 397 + <div className="landing-nav-links"> 398 + <a 399 + href="https://github.com/margin-at/margin" 400 + target="_blank" 401 + rel="noreferrer" 402 + > 403 + GitHub 404 + </a> 405 + <a 406 + href="https://tangled.org/margin.at/margin" 407 + target="_blank" 408 + rel="noreferrer" 409 + > 410 + Tangled 411 + </a> 412 + <a 413 + href="https://bsky.app/profile/margin.at" 414 + target="_blank" 415 + rel="noreferrer" 416 + > 417 + Bluesky 418 + </a> 419 + {user ? ( 420 + <Link to="/home" className="btn btn-primary"> 421 + Open App 422 + </Link> 423 + ) : ( 424 + <Link to="/login" className="btn btn-primary"> 425 + Sign In 426 + </Link> 427 + )} 428 + </div> 429 + </nav> 430 + 431 + <section className="landing-hero"> 432 + <div className="landing-hero-content"> 433 + <div className="landing-badge"> 434 + <SiBluesky size={14} /> 435 + Built on ATProto 436 + </div> 437 + <h1 className="landing-title"> 438 + Write in the margins 439 + <br /> 440 + <span className="landing-title-accent">of the web.</span> 441 + </h1> 442 + <p className="landing-subtitle"> 443 + Margin is a social layer for reading online. Highlight passages, 444 + leave thoughts in the margins, and see what others are thinking 445 + about the pages you read. 446 + </p> 447 + <div className="landing-cta"> 448 + <a 449 + href={ext.url} 450 + target="_blank" 451 + rel="noreferrer" 452 + className="btn btn-primary btn-lg" 453 + > 454 + <ext.Icon size={18} /> 455 + Install for {ext.label} 456 + </a> 457 + {user ? ( 458 + <Link to="/home" className="btn btn-secondary btn-lg"> 459 + Open App 460 + <ArrowRight size={18} /> 461 + </Link> 462 + ) : ( 463 + <Link to="/login" className="btn btn-secondary btn-lg"> 464 + Sign In with ATProto 465 + <ArrowRight size={18} /> 466 + </Link> 467 + )} 468 + </div> 469 + <p className="landing-browsers"> 470 + Also available for{" "} 471 + {isFirefox ? ( 472 + <> 473 + <a 474 + href="https://microsoftedge.microsoft.com/addons/detail/margin/nfjnmllpdgcdnhmmggjihjbidmeadddn" 475 + target="_blank" 476 + rel="noreferrer" 477 + > 478 + Edge 479 + </a>{" "} 480 + and{" "} 481 + <a 482 + href="https://chromewebstore.google.com/detail/margin/cgpmbiiagnehkikhcbnhiagfomajncpa/" 483 + target="_blank" 484 + rel="noreferrer" 485 + > 486 + Chrome 487 + </a> 488 + </> 489 + ) : isEdge ? ( 490 + <> 491 + <a 492 + href="https://addons.mozilla.org/en-US/firefox/addon/margin/" 493 + target="_blank" 494 + rel="noreferrer" 495 + > 496 + Firefox 497 + </a>{" "} 498 + and{" "} 499 + <a 500 + href="https://chromewebstore.google.com/detail/margin/cgpmbiiagnehkikhcbnhiagfomajncpa/" 501 + target="_blank" 502 + rel="noreferrer" 503 + > 504 + Chrome 505 + </a> 506 + </> 507 + ) : ( 508 + <> 509 + <a 510 + href="https://addons.mozilla.org/en-US/firefox/addon/margin/" 511 + target="_blank" 512 + rel="noreferrer" 513 + > 514 + Firefox 515 + </a>{" "} 516 + and{" "} 517 + <a 518 + href="https://microsoftedge.microsoft.com/addons/detail/margin/nfjnmllpdgcdnhmmggjihjbidmeadddn" 519 + target="_blank" 520 + rel="noreferrer" 521 + > 522 + Edge 523 + </a> 524 + </> 525 + )} 526 + </p> 527 + </div> 528 + </section> 529 + 530 + <section className="landing-demo"> 531 + <DemoAnnotation /> 532 + </section> 533 + 534 + <section className="landing-section"> 535 + <h2 className="landing-section-title">How it works</h2> 536 + <div className="landing-steps"> 537 + <div className="landing-step"> 538 + <div className="landing-step-num">1</div> 539 + <div className="landing-step-content"> 540 + <h3>Install & Login</h3> 541 + <p> 542 + Add Margin to your browser and sign in with your AT Protocol 543 + handle. No new account needed, just your existing handle. 544 + </p> 545 + </div> 546 + </div> 547 + <div className="landing-step"> 548 + <div className="landing-step-num">2</div> 549 + <div className="landing-step-content"> 550 + <h3>Annotate the Web</h3> 551 + <p> 552 + Highlight text on any page. Leave notes in the margins, ask 553 + questions, or add context to the conversation precisely where it 554 + belongs. 555 + </p> 556 + </div> 557 + </div> 558 + <div className="landing-step"> 559 + <div className="landing-step-num">3</div> 560 + <div className="landing-step-content"> 561 + <h3>Share & Discover</h3> 562 + <p> 563 + Your annotations are published to your PDS. Discover what the 564 + community is reading and discussing across the web. 565 + </p> 566 + </div> 567 + </div> 568 + </div> 569 + </section> 570 + 571 + <section className="landing-section landing-section-alt"> 572 + <div className="landing-features-grid"> 573 + <div className="landing-feature"> 574 + <div className="landing-feature-icon"> 575 + <Highlighter size={20} /> 576 + </div> 577 + <h3>Universal Highlights</h3> 578 + <p> 579 + Save passages from any article, paper, or post. Your collection 580 + travels with you, independent of any single platform. 581 + </p> 582 + </div> 583 + <div className="landing-feature"> 584 + <div className="landing-feature-icon"> 585 + <MessageSquare size={20} /> 586 + </div> 587 + <h3>Universal Notes</h3> 588 + <p> 589 + Move the discussion out of the comments section. Contextual 590 + conversations that live right alongside the content. 591 + </p> 592 + </div> 593 + <div className="landing-feature"> 594 + <div className="landing-feature-icon"> 595 + <Shield size={20} /> 596 + </div> 597 + <h3>Open Identity</h3> 598 + <p> 599 + Your data, your handle, your graph. Built on the AT Protocol for 600 + true ownership and portability. 601 + </p> 602 + </div> 603 + <div className="landing-feature"> 604 + <div className="landing-feature-icon"> 605 + <Users size={20} /> 606 + </div> 607 + <h3>Community Context</h3> 608 + <p> 609 + See the web with fresh eyes. Discover highlights and notes from 610 + other readers directly on the page. 611 + </p> 612 + </div> 613 + </div> 614 + </section> 615 + 616 + <section className="landing-section landing-protocol"> 617 + <div className="landing-protocol-grid"> 618 + <div className="landing-protocol-main"> 619 + <h2>Your data, your identity</h2> 620 + <p> 621 + Margin is built on the{" "} 622 + <a href="https://atproto.com" target="_blank" rel="noreferrer"> 623 + AT Protocol 624 + </a> 625 + , the same open protocol that powers Bluesky. Sign in with your 626 + existing Bluesky account or create a new one in your preferred 627 + PDS. 628 + </p> 629 + <p> 630 + Your annotations are stored in your PDS. You can export them 631 + anytime, use them with other apps, or self-host your own server. 632 + No vendor lock-in. 633 + </p> 634 + </div> 635 + <div className="landing-protocol-features"> 636 + <div className="landing-protocol-item"> 637 + <Database size={20} /> 638 + <div> 639 + <strong>Portable data</strong> 640 + <span>Export or migrate anytime</span> 641 + </div> 642 + </div> 643 + <div className="landing-protocol-item"> 644 + <Shield size={20} /> 645 + <div> 646 + <strong>You own your identity</strong> 647 + <span>Use your own domain as handle</span> 648 + </div> 649 + </div> 650 + <div className="landing-protocol-item"> 651 + <Zap size={20} /> 652 + <div> 653 + <strong>Interoperable</strong> 654 + <span>Works with the ATProto ecosystem</span> 655 + </div> 656 + </div> 657 + <div className="landing-protocol-item"> 658 + <Github size={20} /> 659 + <div> 660 + <strong>Open source</strong> 661 + <span>Audit, contribute, self-host</span> 662 + </div> 663 + </div> 664 + </div> 665 + </div> 666 + </section> 667 + 668 + <section className="landing-section landing-final-cta"> 669 + <h2>Start annotating today</h2> 670 + <p>Free and open source. Sign in with ATProto to get started.</p> 671 + <div className="landing-cta"> 672 + <a 673 + href={ext.url} 674 + target="_blank" 675 + rel="noreferrer" 676 + className="btn btn-primary btn-lg" 677 + > 678 + <ext.Icon size={18} /> 679 + Get the Extension 680 + </a> 681 + </div> 682 + </section> 683 + 684 + <footer className="landing-footer"> 685 + <div className="landing-footer-grid"> 686 + <div className="landing-footer-brand"> 687 + <Link to="/" className="landing-logo"> 688 + <img src={logo} alt="Margin" /> 689 + <span>Margin</span> 690 + </Link> 691 + <p>Write in the margins of the web.</p> 692 + </div> 693 + <div className="landing-footer-links"> 694 + <div className="landing-footer-col"> 695 + <h4>Product</h4> 696 + <a href={ext.url} target="_blank" rel="noreferrer"> 697 + Browser Extension 698 + </a> 699 + <Link to="/home">Web App</Link> 700 + </div> 701 + <div className="landing-footer-col"> 702 + <h4>Community</h4> 703 + <a 704 + href="https://github.com/margin-at/margin" 705 + target="_blank" 706 + rel="noreferrer" 707 + > 708 + GitHub 709 + </a> 710 + <a 711 + href="https://tangled.org/margin.at/margin" 712 + target="_blank" 713 + rel="noreferrer" 714 + > 715 + Tangled 716 + </a> 717 + <a 718 + href="https://bsky.app/profile/margin.at" 719 + target="_blank" 720 + rel="noreferrer" 721 + > 722 + Bluesky 723 + </a> 724 + </div> 725 + <div className="landing-footer-col"> 726 + <h4>Legal</h4> 727 + <Link to="/privacy">Privacy Policy</Link> 728 + <Link to="/terms">Terms of Service</Link> 729 + </div> 730 + </div> 731 + </div> 732 + <div className="landing-footer-bottom"> 733 + <p>© {new Date().getFullYear()} Margin. Open source under MIT.</p> 734 + </div> 735 + </footer> 736 + </div> 737 + ); 738 + }
+37 -29
web/src/pages/Profile.jsx
··· 181 181 if (authLoading) { 182 182 return ( 183 183 <div className="profile-page"> 184 - <div className="feed"> 185 - {[1, 2, 3].map((i) => ( 186 - <div key={i} className="card"> 187 - <div 188 - className="skeleton skeleton-text" 189 - style={{ width: "40%" }} 190 - /> 191 - <div className="skeleton skeleton-text" /> 192 - <div 193 - className="skeleton skeleton-text" 194 - style={{ width: "60%" }} 195 - /> 196 - </div> 197 - ))} 184 + <div className="feed-container"> 185 + <div className="feed"> 186 + {[1, 2, 3].map((i) => ( 187 + <div key={i} className="card"> 188 + <div 189 + className="skeleton skeleton-text" 190 + style={{ width: "40%" }} 191 + /> 192 + <div className="skeleton skeleton-text" /> 193 + <div 194 + className="skeleton skeleton-text" 195 + style={{ width: "60%" }} 196 + /> 197 + </div> 198 + ))} 199 + </div> 198 200 </div> 199 201 </div> 200 202 ); ··· 594 596 </div> 595 597 596 598 {loading && ( 597 - <div className="feed"> 598 - {[1, 2, 3].map((i) => ( 599 - <div key={i} className="card"> 600 - <div 601 - className="skeleton skeleton-text" 602 - style={{ width: "40%" }} 603 - /> 604 - <div className="skeleton skeleton-text" /> 605 - <div 606 - className="skeleton skeleton-text" 607 - style={{ width: "60%" }} 608 - /> 609 - </div> 610 - ))} 599 + <div className="feed-container"> 600 + <div className="feed"> 601 + {[1, 2, 3].map((i) => ( 602 + <div key={i} className="card"> 603 + <div 604 + className="skeleton skeleton-text" 605 + style={{ width: "40%" }} 606 + /> 607 + <div className="skeleton skeleton-text" /> 608 + <div 609 + className="skeleton skeleton-text" 610 + style={{ width: "60%" }} 611 + /> 612 + </div> 613 + ))} 614 + </div> 611 615 </div> 612 616 )} 613 617 ··· 619 623 </div> 620 624 )} 621 625 622 - {!loading && !error && <div className="feed">{renderContent()}</div>} 626 + {!loading && !error && ( 627 + <div className="feed-container"> 628 + <div className="feed">{renderContent()}</div> 629 + </div> 630 + )} 623 631 </div> 624 632 ); 625 633 }
+3 -1
web/src/pages/Url.jsx
··· 380 380 </div> 381 381 )} 382 382 383 - <div className="feed">{renderResults()}</div> 383 + <div className="feed-container"> 384 + <div className="feed">{renderResults()}</div> 385 + </div> 384 386 </> 385 387 )} 386 388 </div>
+19 -15
web/src/pages/UserUrl.jsx
··· 163 163 </div> 164 164 165 165 {loading && ( 166 - <div className="feed"> 167 - {[1, 2, 3].map((i) => ( 168 - <div key={i} className="card"> 169 - <div 170 - className="skeleton skeleton-text" 171 - style={{ width: "40%" }} 172 - /> 173 - <div className="skeleton skeleton-text" /> 174 - <div 175 - className="skeleton skeleton-text" 176 - style={{ width: "60%" }} 177 - /> 178 - </div> 179 - ))} 166 + <div className="feed-container"> 167 + <div className="feed"> 168 + {[1, 2, 3].map((i) => ( 169 + <div key={i} className="card"> 170 + <div 171 + className="skeleton skeleton-text" 172 + style={{ width: "40%" }} 173 + /> 174 + <div className="skeleton skeleton-text" /> 175 + <div 176 + className="skeleton skeleton-text" 177 + style={{ width: "60%" }} 178 + /> 179 + </div> 180 + ))} 181 + </div> 180 182 </div> 181 183 )} 182 184 ··· 227 229 </button> 228 230 </div> 229 231 </div> 230 - <div className="feed">{renderResults()}</div> 232 + <div className="feed-container"> 233 + <div className="feed">{renderResults()}</div> 234 + </div> 231 235 </> 232 236 )} 233 237 </div>